From 7c25036ed9fb4736b04d05ca3491d3dfed0b2694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Fri, 20 Mar 2026 10:10:37 -0600 Subject: [PATCH] =?UTF-8?q?feat(gsd):=20GitHub=20sync=20extension=20?= =?UTF-8?q?=E2=80=94=20auto-sync=20to=20Issues,=20PRs,=20Milestones=20(#16?= =?UTF-8?q?03)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gsd): GitHub sync extension — auto-sync lifecycle events to Issues, PRs, Milestones Standalone opt-in extension at src/resources/extensions/github-sync/ that syncs GSD lifecycle events to GitHub as a presentation layer. Local .gsd/ files remain source of truth; GitHub is fire-and-forget. Lifecycle mapping: - plan-milestone → GH Milestone + tracking Issue (roadmap body) - plan-slice → slice branch + draft PR + task sub-issues - execute-task → summary comment + close task issue + Resolves #N commit - complete-slice → mark PR ready + squash-merge into milestone branch - complete-milestone → close GH Milestone + tracking issue GSD core changes (minimal): - preferences: add `github` config key with validation and merge logic - auto-post-unit: single dynamic import integration point after auto-commit - git-service: `issueNumber` field on TaskCommitContext for Resolves #N trailer Co-Authored-By: Claude Opus 4.6 (1M context) * fix: strict TS casts for SummaryFrontmatter and GitHubSyncConfig CI tsconfig requires double-cast through unknown for interfaces without index signatures. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/github-sync/cli.ts | 364 ++++++++++++ src/resources/extensions/github-sync/index.ts | 93 +++ .../extensions/github-sync/mapping.ts | 81 +++ src/resources/extensions/github-sync/sync.ts | 556 ++++++++++++++++++ .../extensions/github-sync/templates.ts | 183 ++++++ .../extensions/github-sync/tests/cli.test.ts | 20 + .../github-sync/tests/commit-linking.test.ts | 39 ++ .../github-sync/tests/mapping.test.ts | 104 ++++ .../github-sync/tests/templates.test.ts | 110 ++++ src/resources/extensions/github-sync/types.ts | 47 ++ .../extensions/gsd/auto-post-unit.ts | 18 + src/resources/extensions/gsd/git-service.ts | 14 +- .../extensions/gsd/preferences-types.ts | 4 + .../extensions/gsd/preferences-validation.ts | 50 ++ src/resources/extensions/gsd/preferences.ts | 3 + 15 files changed, 1685 insertions(+), 1 deletion(-) create mode 100644 src/resources/extensions/github-sync/cli.ts create mode 100644 src/resources/extensions/github-sync/index.ts create mode 100644 src/resources/extensions/github-sync/mapping.ts create mode 100644 src/resources/extensions/github-sync/sync.ts create mode 100644 src/resources/extensions/github-sync/templates.ts create mode 100644 src/resources/extensions/github-sync/tests/cli.test.ts create mode 100644 src/resources/extensions/github-sync/tests/commit-linking.test.ts create mode 100644 src/resources/extensions/github-sync/tests/mapping.test.ts create mode 100644 src/resources/extensions/github-sync/tests/templates.test.ts create mode 100644 src/resources/extensions/github-sync/types.ts diff --git a/src/resources/extensions/github-sync/cli.ts b/src/resources/extensions/github-sync/cli.ts new file mode 100644 index 000000000..2c6d3a43b --- /dev/null +++ b/src/resources/extensions/github-sync/cli.ts @@ -0,0 +1,364 @@ +/** + * Thin wrapper around the `gh` CLI. + * + * Every public function returns `GhResult` — never throws. + * Uses `execFileSync` (not `execSync`) for safety. + */ + +import { execFileSync } from "node:child_process"; + +// ─── Result Type ──────────────────────────────────────────────────────────── + +export interface GhResult { + ok: boolean; + data?: T; + error?: string; +} + +function ok(data: T): GhResult { + return { ok: true, data }; +} + +function fail(error: string): GhResult { + return { ok: false, error }; +} + +// ─── gh Availability ──────────────────────────────────────────────────────── + +let _ghAvailable: boolean | null = null; + +export function ghIsAvailable(): boolean { + if (_ghAvailable !== null) return _ghAvailable; + try { + execFileSync("gh", ["--version"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5_000, + }); + _ghAvailable = true; + } catch { + _ghAvailable = false; + } + return _ghAvailable; +} + +/** Reset cached availability (for testing). */ +export function _resetGhCache(): void { + _ghAvailable = null; +} + +// ─── Rate Limit Check ─────────────────────────────────────────────────────── + +let _rateLimitCheckedAt = 0; +let _rateLimitOk = true; +const RATE_LIMIT_CHECK_INTERVAL_MS = 300_000; // 5 minutes + +export function ghHasRateLimit(cwd: string): boolean { + const now = Date.now(); + if (now - _rateLimitCheckedAt < RATE_LIMIT_CHECK_INTERVAL_MS) return _rateLimitOk; + _rateLimitCheckedAt = now; + try { + const raw = execFileSync("gh", ["api", "rate_limit", "--jq", ".rate.remaining"], { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 10_000, + }).trim(); + const remaining = parseInt(raw, 10); + _rateLimitOk = Number.isFinite(remaining) && remaining >= 100; + } catch { + // Can't check — assume OK so we don't silently disable sync + _rateLimitOk = true; + } + return _rateLimitOk; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +const GH_TIMEOUT = 15_000; +const MAX_BODY_LENGTH = 65_000; + +function truncateBody(body: string): string { + if (body.length <= MAX_BODY_LENGTH) return body; + return body.slice(0, MAX_BODY_LENGTH) + "\n\n---\n*Body truncated (exceeded 65K characters)*"; +} + +function runGh(args: string[], cwd: string): GhResult { + try { + const stdout = execFileSync("gh", args, { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + timeout: GH_TIMEOUT, + }).trim(); + return ok(stdout); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return fail(msg); + } +} + +function runGhJson(args: string[], cwd: string): GhResult { + const result = runGh(args, cwd); + if (!result.ok) return fail(result.error!); + try { + return ok(JSON.parse(result.data!) as T); + } catch { + return fail(`Failed to parse JSON: ${result.data}`); + } +} + +// ─── Repo Detection ───────────────────────────────────────────────────────── + +export function ghDetectRepo(cwd: string): GhResult { + const result = runGh( + ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], + cwd, + ); + if (!result.ok) return fail(result.error!); + const repo = result.data!.trim(); + if (!repo || !repo.includes("/")) return fail("Could not detect repo"); + return ok(repo); +} + +// ─── Issues ───────────────────────────────────────────────────────────────── + +export interface CreateIssueOpts { + repo: string; + title: string; + body: string; + labels?: string[]; + milestone?: number; + parentIssue?: number; +} + +export function ghCreateIssue(cwd: string, opts: CreateIssueOpts): GhResult { + const args = [ + "issue", "create", + "--repo", opts.repo, + "--title", opts.title, + "--body", truncateBody(opts.body), + ]; + if (opts.labels?.length) { + args.push("--label", opts.labels.join(",")); + } + if (opts.milestone) { + args.push("--milestone", String(opts.milestone)); + } + + const result = runGh(args, cwd); + if (!result.ok) return fail(result.error!); + + // gh issue create returns the URL; extract issue number + const match = result.data!.match(/\/issues\/(\d+)/); + if (!match) return fail(`Could not parse issue number from: ${result.data}`); + const issueNumber = parseInt(match[1], 10); + + // If parent specified, add as sub-issue via GraphQL + if (opts.parentIssue) { + ghAddSubIssue(cwd, opts.repo, opts.parentIssue, issueNumber); + } + + return ok(issueNumber); +} + +export function ghCloseIssue(cwd: string, repo: string, issueNumber: number, comment?: string): GhResult { + if (comment) { + ghAddComment(cwd, repo, issueNumber, comment); + } + const result = runGh( + ["issue", "close", String(issueNumber), "--repo", repo], + cwd, + ); + if (!result.ok) return fail(result.error!); + return ok(undefined); +} + +export function ghAddComment(cwd: string, repo: string, issueNumber: number, body: string): GhResult { + const result = runGh( + ["issue", "comment", String(issueNumber), "--repo", repo, "--body", truncateBody(body)], + cwd, + ); + if (!result.ok) return fail(result.error!); + return ok(undefined); +} + +// ─── Sub-Issues (GraphQL) ─────────────────────────────────────────────────── + +function ghAddSubIssue(cwd: string, repo: string, parentNumber: number, childNumber: number): GhResult { + // Get node IDs for both issues + const parentResult = runGhJson<{ id: string }>( + ["api", `repos/${repo}/issues/${parentNumber}`, "--jq", "{id: .node_id}"], + cwd, + ); + const childResult = runGhJson<{ id: string }>( + ["api", `repos/${repo}/issues/${childNumber}`, "--jq", "{id: .node_id}"], + cwd, + ); + + if (!parentResult.ok || !childResult.ok) { + return fail("Could not resolve issue node IDs for sub-issue linking"); + } + + const mutation = `mutation { addSubIssue(input: { issueId: "${parentResult.data!.id}", subIssueId: "${childResult.data!.id}" }) { issue { id } } }`; + return runGh(["api", "graphql", "-f", `query=${mutation}`], cwd) as GhResult; +} + +// ─── Milestones ───────────────────────────────────────────────────────────── + +export function ghCreateMilestone(cwd: string, repo: string, title: string, description: string): GhResult { + const result = runGhJson<{ number: number }>( + [ + "api", `repos/${repo}/milestones`, + "-X", "POST", + "-f", `title=${title}`, + "-f", `description=${truncateBody(description)}`, + "-f", "state=open", + "--jq", "{number: .number}", + ], + cwd, + ); + if (!result.ok) return fail(result.error!); + return ok(result.data!.number); +} + +export function ghCloseMilestone(cwd: string, repo: string, milestoneNumber: number): GhResult { + const result = runGh( + [ + "api", `repos/${repo}/milestones/${milestoneNumber}`, + "-X", "PATCH", + "-f", "state=closed", + ], + cwd, + ); + if (!result.ok) return fail(result.error!); + return ok(undefined); +} + +// ─── Pull Requests ────────────────────────────────────────────────────────── + +export interface CreatePROpts { + repo: string; + base: string; + head: string; + title: string; + body: string; + draft?: boolean; +} + +export function ghCreatePR(cwd: string, opts: CreatePROpts): GhResult { + const args = [ + "pr", "create", + "--repo", opts.repo, + "--base", opts.base, + "--head", opts.head, + "--title", opts.title, + "--body", truncateBody(opts.body), + ]; + if (opts.draft) args.push("--draft"); + + const result = runGh(args, cwd); + if (!result.ok) return fail(result.error!); + + const match = result.data!.match(/\/pull\/(\d+)/); + if (!match) return fail(`Could not parse PR number from: ${result.data}`); + return ok(parseInt(match[1], 10)); +} + +export function ghMarkPRReady(cwd: string, repo: string, prNumber: number): GhResult { + const result = runGh( + ["pr", "ready", String(prNumber), "--repo", repo], + cwd, + ); + if (!result.ok) return fail(result.error!); + return ok(undefined); +} + +export function ghMergePR(cwd: string, repo: string, prNumber: number, strategy: "squash" | "merge" = "squash"): GhResult { + const args = [ + "pr", "merge", String(prNumber), + "--repo", repo, + strategy === "squash" ? "--squash" : "--merge", + "--delete-branch", + ]; + const result = runGh(args, cwd); + if (!result.ok) return fail(result.error!); + return ok(undefined); +} + +// ─── Projects v2 ──────────────────────────────────────────────────────────── + +export function ghAddToProject(cwd: string, repo: string, projectNumber: number, issueNumber: number): GhResult { + // Get the issue's node ID first + const issueResult = runGhJson<{ id: string }>( + ["api", `repos/${repo}/issues/${issueNumber}`, "--jq", "{id: .node_id}"], + cwd, + ); + if (!issueResult.ok) return fail(issueResult.error!); + + // Get the project's node ID + const [owner] = repo.split("/"); + const projectResult = runGhJson<{ id: string }>( + [ + "api", "graphql", + "-f", `query=query { user(login: "${owner}") { projectV2(number: ${projectNumber}) { id } } }`, + "--jq", ".data.user.projectV2.id", + ], + cwd, + ); + + // Try org if user fails + let projectId: string | undefined; + if (projectResult.ok && projectResult.data?.id) { + projectId = projectResult.data.id; + } else { + const orgResult = runGhJson<{ id: string }>( + [ + "api", "graphql", + "-f", `query=query { organization(login: "${owner}") { projectV2(number: ${projectNumber}) { id } } }`, + "--jq", ".data.organization.projectV2.id", + ], + cwd, + ); + if (orgResult.ok) projectId = orgResult.data?.id; + } + + if (!projectId) return fail("Could not find project"); + + const mutation = `mutation { addProjectV2ItemById(input: { projectId: "${projectId}", contentId: "${issueResult.data!.id}" }) { item { id } } }`; + return runGh(["api", "graphql", "-f", `query=${mutation}`], cwd) as GhResult; +} + +// ─── Branch Operations ────────────────────────────────────────────────────── + +export function ghPushBranch(cwd: string, branch: string, setUpstream = true): GhResult { + const args = ["git", "push"]; + if (setUpstream) args.push("-u", "origin", branch); + else args.push("origin", branch); + + try { + execFileSync(args[0], args.slice(1), { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 30_000, + }); + return ok(undefined); + } catch (err) { + return fail(err instanceof Error ? err.message : String(err)); + } +} + +export function ghCreateBranch(cwd: string, branch: string, from: string): GhResult { + try { + execFileSync("git", ["branch", branch, from], { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 10_000, + }); + return ok(undefined); + } catch (err) { + return fail(err instanceof Error ? err.message : String(err)); + } +} diff --git a/src/resources/extensions/github-sync/index.ts b/src/resources/extensions/github-sync/index.ts new file mode 100644 index 000000000..9f6732f19 --- /dev/null +++ b/src/resources/extensions/github-sync/index.ts @@ -0,0 +1,93 @@ +/** + * GitHub Sync extension for GSD. + * + * Opt-in extension that syncs GSD lifecycle events to GitHub: + * milestones → GH Milestones + tracking issues, slices → draft PRs, + * tasks → sub-issues with auto-close on commit. + * + * Integration happens via a single dynamic import in auto-post-unit.ts. + * This index registers a `/github-sync` command for manual bootstrap + * and status display. + */ + +import type { ExtensionAPI } from "@gsd/pi-coding-agent"; +import { bootstrapSync } from "./sync.js"; +import { loadSyncMapping } from "./mapping.js"; +import { ghIsAvailable } from "./cli.js"; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("github-sync", { + description: "Bootstrap GitHub sync or show sync status", + handler: async (args: string, ctx) => { + const subcommand = args.trim().toLowerCase(); + + if (subcommand === "status") { + await showStatus(ctx); + return; + } + + if (subcommand === "bootstrap" || subcommand === "") { + await runBootstrap(ctx); + return; + } + + ctx.ui.notify( + "Usage: /github-sync [bootstrap|status]", + "info", + ); + }, + }); +} + +async function showStatus(ctx: import("@gsd/pi-coding-agent").ExtensionCommandContext) { + if (!ghIsAvailable()) { + ctx.ui.notify("GitHub sync: `gh` CLI not installed or not authenticated.", "warning"); + return; + } + + const mapping = loadSyncMapping(ctx.cwd); + if (!mapping) { + ctx.ui.notify("GitHub sync: No sync mapping found. Run `/github-sync bootstrap` to initialize.", "info"); + return; + } + + const milestoneCount = Object.keys(mapping.milestones).length; + const sliceCount = Object.keys(mapping.slices).length; + const taskCount = Object.keys(mapping.tasks).length; + const openMilestones = Object.values(mapping.milestones).filter(m => m.state === "open").length; + const openSlices = Object.values(mapping.slices).filter(s => s.state === "open").length; + const openTasks = Object.values(mapping.tasks).filter(t => t.state === "open").length; + + ctx.ui.notify( + [ + `GitHub sync: repo=${mapping.repo}`, + ` Milestones: ${milestoneCount} (${openMilestones} open)`, + ` Slices: ${sliceCount} (${openSlices} open)`, + ` Tasks: ${taskCount} (${openTasks} open)`, + ].join("\n"), + "info", + ); +} + +async function runBootstrap(ctx: import("@gsd/pi-coding-agent").ExtensionCommandContext) { + if (!ghIsAvailable()) { + ctx.ui.notify("GitHub sync: `gh` CLI not installed or not authenticated.", "warning"); + return; + } + + ctx.ui.notify("GitHub sync: bootstrapping...", "info"); + + try { + const counts = await bootstrapSync(ctx.cwd); + if (counts.milestones === 0 && counts.slices === 0 && counts.tasks === 0) { + ctx.ui.notify("GitHub sync: everything already synced (or no milestones found).", "info"); + } else { + ctx.ui.notify( + `GitHub sync: created ${counts.milestones} milestone(s), ${counts.slices} slice(s), ${counts.tasks} task(s).`, + "info", + ); + } + } catch (err) { + ctx.ui.notify(`GitHub sync bootstrap failed: ${err}`, "error"); + } +} diff --git a/src/resources/extensions/github-sync/mapping.ts b/src/resources/extensions/github-sync/mapping.ts new file mode 100644 index 000000000..f63befec8 --- /dev/null +++ b/src/resources/extensions/github-sync/mapping.ts @@ -0,0 +1,81 @@ +/** + * Persistence layer for the GitHub sync mapping. + * + * The mapping lives at `.gsd/github-sync.json` and tracks which GSD + * entities have been synced to which GitHub entities (issues, PRs, + * milestones) along with their numbers and sync timestamps. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteSync } from "../gsd/atomic-write.js"; +import type { SyncMapping, MilestoneSyncRecord, SliceSyncRecord, SyncEntityRecord } from "./types.js"; + +const MAPPING_FILENAME = "github-sync.json"; + +function mappingPath(basePath: string): string { + return join(basePath, ".gsd", MAPPING_FILENAME); +} + +// ─── Load / Save ──────────────────────────────────────────────────────────── + +export function loadSyncMapping(basePath: string): SyncMapping | null { + const path = mappingPath(basePath); + if (!existsSync(path)) return null; + try { + const raw = readFileSync(path, "utf-8"); + const parsed = JSON.parse(raw); + if (parsed?.version !== 1) return null; + return parsed as SyncMapping; + } catch { + return null; + } +} + +export function saveSyncMapping(basePath: string, mapping: SyncMapping): void { + const path = mappingPath(basePath); + atomicWriteSync(path, JSON.stringify(mapping, null, 2) + "\n"); +} + +export function createEmptyMapping(repo: string): SyncMapping { + return { + version: 1, + repo, + milestones: {}, + slices: {}, + tasks: {}, + }; +} + +// ─── Accessors ────────────────────────────────────────────────────────────── + +export function getMilestoneRecord(mapping: SyncMapping, mid: string): MilestoneSyncRecord | null { + return mapping.milestones[mid] ?? null; +} + +export function getSliceRecord(mapping: SyncMapping, mid: string, sid: string): SliceSyncRecord | null { + return mapping.slices[`${mid}/${sid}`] ?? null; +} + +export function getTaskRecord(mapping: SyncMapping, mid: string, sid: string, tid: string): SyncEntityRecord | null { + return mapping.tasks[`${mid}/${sid}/${tid}`] ?? null; +} + +export function getTaskIssueNumber(mapping: SyncMapping, mid: string, sid: string, tid: string): number | null { + const record = getTaskRecord(mapping, mid, sid, tid); + return record?.issueNumber ?? null; +} + +// ─── Mutators ─────────────────────────────────────────────────────────────── + +export function setMilestoneRecord(mapping: SyncMapping, mid: string, record: MilestoneSyncRecord): void { + mapping.milestones[mid] = record; +} + +export function setSliceRecord(mapping: SyncMapping, mid: string, sid: string, record: SliceSyncRecord): void { + mapping.slices[`${mid}/${sid}`] = record; +} + +export function setTaskRecord(mapping: SyncMapping, mid: string, sid: string, tid: string, record: SyncEntityRecord): void { + mapping.tasks[`${mid}/${sid}/${tid}`] = record; +} diff --git a/src/resources/extensions/github-sync/sync.ts b/src/resources/extensions/github-sync/sync.ts new file mode 100644 index 000000000..2fc5fac3a --- /dev/null +++ b/src/resources/extensions/github-sync/sync.ts @@ -0,0 +1,556 @@ +/** + * Core GitHub sync engine. + * + * Entry point: `runGitHubSync()` — called from the GSD post-unit pipeline. + * Routes to per-event sync functions based on the unit type, reads GSD + * files to build GitHub entities, and persists the sync mapping. + * + * All errors are caught internally — sync failures never block execution. + */ + +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { loadFile, parseRoadmap, parsePlan, parseSummary } from "../gsd/files.js"; +import { + resolveMilestoneFile, + resolveSliceFile, + resolveTaskFile, +} from "../gsd/paths.js"; +import { debugLog } from "../gsd/debug-logger.js"; +import { loadEffectiveGSDPreferences } from "../gsd/preferences.js"; + +import type { GitHubSyncConfig, SyncMapping } from "./types.js"; +import { + loadSyncMapping, + saveSyncMapping, + createEmptyMapping, + getMilestoneRecord, + getSliceRecord, + getTaskRecord, + setMilestoneRecord, + setSliceRecord, + setTaskRecord, +} from "./mapping.js"; +import { + ghIsAvailable, + ghHasRateLimit, + ghDetectRepo, + ghCreateIssue, + ghCloseIssue, + ghAddComment, + ghCreateMilestone, + ghCloseMilestone, + ghCreatePR, + ghMarkPRReady, + ghMergePR, + ghCreateBranch, + ghPushBranch, + ghAddToProject, +} from "./cli.js"; +import { + formatMilestoneIssueBody, + formatSlicePRBody, + formatTaskIssueBody, + formatSummaryComment, +} from "./templates.js"; + +// ─── Entry Point ──────────────────────────────────────────────────────────── + +/** + * Main sync entry point — called from GSD post-unit pipeline. + * Routes to the appropriate sync function based on unit type. + */ +export async function runGitHubSync( + basePath: string, + unitType: string, + unitId: string, +): Promise { + try { + const config = loadGitHubSyncConfig(basePath); + if (!config?.enabled) return; + if (!ghIsAvailable()) { + debugLog("github-sync", { skip: "gh CLI not available" }); + return; + } + + // Resolve repo + const repo = config.repo ?? resolveRepo(basePath); + if (!repo) { + debugLog("github-sync", { skip: "could not detect repo" }); + return; + } + + // Rate limit check + if (!ghHasRateLimit(basePath)) { + debugLog("github-sync", { skip: "rate limit low" }); + return; + } + + // Load or init mapping + let mapping = loadSyncMapping(basePath) ?? createEmptyMapping(repo); + mapping.repo = repo; + + // Parse unit ID parts + const parts = unitId.split("/"); + const [mid, sid, tid] = parts; + + // Route by unit type + switch (unitType) { + case "plan-milestone": + if (mid) await syncMilestonePlan(basePath, mapping, config, mid); + break; + case "plan-slice": + case "research-slice": + if (mid && sid) await syncSlicePlan(basePath, mapping, config, mid, sid); + break; + case "execute-task": + case "reactive-execute": + if (mid && sid && tid) await syncTaskComplete(basePath, mapping, config, mid, sid, tid); + break; + case "complete-slice": + if (mid && sid) await syncSliceComplete(basePath, mapping, config, mid, sid); + break; + case "complete-milestone": + if (mid) await syncMilestoneComplete(basePath, mapping, config, mid); + break; + } + + saveSyncMapping(basePath, mapping); + } catch (err) { + debugLog("github-sync", { error: String(err) }); + } +} + +// ─── Per-Event Sync Functions ─────────────────────────────────────────────── + +async function syncMilestonePlan( + basePath: string, + mapping: SyncMapping, + config: GitHubSyncConfig, + mid: string, +): Promise { + // Skip if already synced + if (getMilestoneRecord(mapping, mid)) return; + + // Load roadmap data + const roadmapPath = resolveMilestoneFile(basePath, mid, "ROADMAP"); + if (!roadmapPath) return; + const content = await loadFile(roadmapPath); + if (!content) return; + + const roadmap = parseRoadmap(content); + const title = `${mid}: ${roadmap.title || "Milestone"}`; + + // Create GitHub Milestone + const milestoneResult = ghCreateMilestone( + basePath, + mapping.repo, + title, + roadmap.vision || "", + ); + if (!milestoneResult.ok) { + debugLog("github-sync", { phase: "create-milestone", error: milestoneResult.error }); + return; + } + const ghMilestoneNumber = milestoneResult.data!; + + // Create tracking issue + const issueBody = formatMilestoneIssueBody({ + id: mid, + title: roadmap.title || "Milestone", + vision: roadmap.vision, + successCriteria: roadmap.successCriteria, + slices: roadmap.slices?.map(s => ({ + id: s.id, + title: s.title, + })), + }); + + const issueResult = ghCreateIssue(basePath, { + repo: mapping.repo, + title: `${mid}: ${roadmap.title || "Milestone"} — Tracking`, + body: issueBody, + labels: config.labels, + milestone: ghMilestoneNumber, + }); + if (!issueResult.ok) { + debugLog("github-sync", { phase: "create-tracking-issue", error: issueResult.error }); + return; + } + + // Add to project if configured + if (config.project) { + ghAddToProject(basePath, mapping.repo, config.project, issueResult.data!); + } + + setMilestoneRecord(mapping, mid, { + issueNumber: issueResult.data!, + ghMilestoneNumber, + lastSyncedAt: new Date().toISOString(), + state: "open", + }); + + debugLog("github-sync", { + phase: "milestone-synced", + mid, + milestone: ghMilestoneNumber, + issue: issueResult.data, + }); +} + +async function syncSlicePlan( + basePath: string, + mapping: SyncMapping, + config: GitHubSyncConfig, + mid: string, + sid: string, +): Promise { + // Skip if already synced + if (getSliceRecord(mapping, mid, sid)) return; + + // Ensure milestone is synced first + if (!getMilestoneRecord(mapping, mid)) { + await syncMilestonePlan(basePath, mapping, config, mid); + } + const milestoneRecord = getMilestoneRecord(mapping, mid); + + // Load slice plan + const planPath = resolveSliceFile(basePath, mid, sid, "PLAN"); + if (!planPath) return; + const content = await loadFile(planPath); + if (!content) return; + + const plan = parsePlan(content); + const sliceBranch = `milestone/${mid}/${sid}`; + const milestoneBranch = `milestone/${mid}`; + + // Create task sub-issues first (so we can link them in the PR body) + const taskIssueNumbers: Array<{ id: string; title: string; issueNumber?: number }> = []; + + if (plan.tasks) { + for (const task of plan.tasks) { + // Skip if already synced + if (getTaskRecord(mapping, mid, sid, task.id)) { + const existing = getTaskRecord(mapping, mid, sid, task.id)!; + taskIssueNumbers.push({ id: task.id, title: task.title, issueNumber: existing.issueNumber }); + continue; + } + + const taskBody = formatTaskIssueBody({ + id: task.id, + title: task.title, + description: task.description, + files: task.files, + verifyCriteria: task.verify ? [task.verify] : undefined, + }); + + const taskResult = ghCreateIssue(basePath, { + repo: mapping.repo, + title: `${mid}/${sid}/${task.id}: ${task.title}`, + body: taskBody, + labels: config.labels, + milestone: milestoneRecord?.ghMilestoneNumber, + parentIssue: milestoneRecord?.issueNumber, + }); + + if (taskResult.ok) { + setTaskRecord(mapping, mid, sid, task.id, { + issueNumber: taskResult.data!, + lastSyncedAt: new Date().toISOString(), + state: "open", + }); + taskIssueNumbers.push({ id: task.id, title: task.title, issueNumber: taskResult.data! }); + + if (config.project) { + ghAddToProject(basePath, mapping.repo, config.project, taskResult.data!); + } + } else { + taskIssueNumbers.push({ id: task.id, title: task.title }); + } + } + } + + if (config.slice_prs === false) { + // Slice PRs disabled — just record without PR + setSliceRecord(mapping, mid, sid, { + issueNumber: 0, + prNumber: 0, + branch: sliceBranch, + lastSyncedAt: new Date().toISOString(), + state: "open", + }); + return; + } + + // Create slice branch from milestone branch + const branchResult = ghCreateBranch(basePath, sliceBranch, milestoneBranch); + if (!branchResult.ok) { + debugLog("github-sync", { phase: "create-slice-branch", error: branchResult.error }); + // Branch might already exist — continue anyway + } + + // Push the slice branch + const pushResult = ghPushBranch(basePath, sliceBranch); + if (!pushResult.ok) { + debugLog("github-sync", { phase: "push-slice-branch", error: pushResult.error }); + } + + // Create draft PR + const prBody = formatSlicePRBody({ + id: sid, + title: plan.title || sid, + goal: plan.goal, + mustHaves: plan.mustHaves, + demoCriterion: plan.demo, + tasks: taskIssueNumbers, + }); + + const prResult = ghCreatePR(basePath, { + repo: mapping.repo, + base: milestoneBranch, + head: sliceBranch, + title: `${sid}: ${plan.title || sid}`, + body: prBody, + draft: true, + }); + + const prNumber = prResult.ok ? prResult.data! : 0; + if (!prResult.ok) { + debugLog("github-sync", { phase: "create-slice-pr", error: prResult.error }); + } + + setSliceRecord(mapping, mid, sid, { + issueNumber: 0, // Slice doesn't get its own issue — tracked via PR + prNumber, + branch: sliceBranch, + lastSyncedAt: new Date().toISOString(), + state: "open", + }); + + debugLog("github-sync", { + phase: "slice-synced", + mid, + sid, + pr: prNumber, + taskIssues: taskIssueNumbers.filter(t => t.issueNumber).length, + }); +} + +async function syncTaskComplete( + basePath: string, + mapping: SyncMapping, + config: GitHubSyncConfig, + mid: string, + sid: string, + tid: string, +): Promise { + const taskRecord = getTaskRecord(mapping, mid, sid, tid); + if (!taskRecord || taskRecord.state === "closed") return; + + // Load task summary + const summaryPath = resolveTaskFile(basePath, mid, sid, tid, "SUMMARY"); + if (summaryPath) { + const content = await loadFile(summaryPath); + if (content) { + const summary = parseSummary(content); + const comment = formatSummaryComment({ + oneLiner: summary.oneLiner, + body: summary.whatHappened, + frontmatter: summary.frontmatter as unknown as Record, + }); + ghAddComment(basePath, mapping.repo, taskRecord.issueNumber, comment); + } + } + + // Close the task issue + ghCloseIssue(basePath, mapping.repo, taskRecord.issueNumber); + + taskRecord.state = "closed"; + taskRecord.lastSyncedAt = new Date().toISOString(); + setTaskRecord(mapping, mid, sid, tid, taskRecord); + + debugLog("github-sync", { phase: "task-closed", mid, sid, tid, issue: taskRecord.issueNumber }); +} + +async function syncSliceComplete( + basePath: string, + mapping: SyncMapping, + config: GitHubSyncConfig, + mid: string, + sid: string, +): Promise { + const sliceRecord = getSliceRecord(mapping, mid, sid); + if (!sliceRecord || sliceRecord.state === "closed") return; + + // Post slice summary as PR comment + const summaryPath = resolveSliceFile(basePath, mid, sid, "SUMMARY"); + if (summaryPath && sliceRecord.prNumber) { + const content = await loadFile(summaryPath); + if (content) { + const summary = parseSummary(content); + const comment = formatSummaryComment({ + oneLiner: summary.oneLiner, + body: summary.whatHappened, + frontmatter: summary.frontmatter as unknown as Record, + }); + ghAddComment(basePath, mapping.repo, sliceRecord.prNumber, comment); + } + } + + // Mark PR ready for review, then merge + if (sliceRecord.prNumber) { + ghMarkPRReady(basePath, mapping.repo, sliceRecord.prNumber); + // Squash-merge into milestone branch + ghMergePR(basePath, mapping.repo, sliceRecord.prNumber, "squash"); + } + + sliceRecord.state = "closed"; + sliceRecord.lastSyncedAt = new Date().toISOString(); + setSliceRecord(mapping, mid, sid, sliceRecord); + + debugLog("github-sync", { phase: "slice-completed", mid, sid, pr: sliceRecord.prNumber }); +} + +async function syncMilestoneComplete( + basePath: string, + mapping: SyncMapping, + config: GitHubSyncConfig, + mid: string, +): Promise { + const record = getMilestoneRecord(mapping, mid); + if (!record || record.state === "closed") return; + + // Close tracking issue + ghCloseIssue( + basePath, + mapping.repo, + record.issueNumber, + `Milestone ${mid} completed.`, + ); + + // Close GitHub milestone + ghCloseMilestone(basePath, mapping.repo, record.ghMilestoneNumber); + + record.state = "closed"; + record.lastSyncedAt = new Date().toISOString(); + setMilestoneRecord(mapping, mid, record); + + debugLog("github-sync", { phase: "milestone-completed", mid }); +} + +// ─── Bootstrap ────────────────────────────────────────────────────────────── + +/** + * Walk the `.gsd/milestones/` tree and create GitHub entities for any + * that are missing from the sync mapping. Safe to run multiple times. + */ +export async function bootstrapSync(basePath: string): Promise<{ + milestones: number; + slices: number; + tasks: number; +}> { + const config = loadGitHubSyncConfig(basePath); + if (!config?.enabled) return { milestones: 0, slices: 0, tasks: 0 }; + if (!ghIsAvailable()) return { milestones: 0, slices: 0, tasks: 0 }; + + const repo = config.repo ?? resolveRepo(basePath); + if (!repo) return { milestones: 0, slices: 0, tasks: 0 }; + + let mapping = loadSyncMapping(basePath) ?? createEmptyMapping(repo); + mapping.repo = repo; + + const taskCountBefore = Object.keys(mapping.tasks).length; + const counts = { milestones: 0, slices: 0, tasks: 0 }; + const milestonesDir = join(basePath, ".gsd", "milestones"); + if (!existsSync(milestonesDir)) return counts; + + const milestoneIds = readdirSync(milestonesDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name) + .sort(); + + for (const mid of milestoneIds) { + if (!getMilestoneRecord(mapping, mid)) { + await syncMilestonePlan(basePath, mapping, config, mid); + counts.milestones++; + } + + // Find slices + const slicesDir = join(milestonesDir, mid, "slices"); + if (!existsSync(slicesDir)) continue; + + const sliceIds = readdirSync(slicesDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name) + .sort(); + + for (const sid of sliceIds) { + if (!getSliceRecord(mapping, mid, sid)) { + await syncSlicePlan(basePath, mapping, config, mid, sid); + counts.slices++; + } + } + } + + counts.tasks = Object.keys(mapping.tasks).length - taskCountBefore; + saveSyncMapping(basePath, mapping); + return counts; +} + +// ─── Config Loading ───────────────────────────────────────────────────────── + +let _cachedConfig: GitHubSyncConfig | null | undefined; + +function loadGitHubSyncConfig(_basePath: string): GitHubSyncConfig | null { + if (_cachedConfig !== undefined) return _cachedConfig; + try { + const prefs = loadEffectiveGSDPreferences(); + const github = (prefs?.preferences as Record)?.github; + if (!github || typeof github !== "object") { + _cachedConfig = null; + return null; + } + _cachedConfig = github as GitHubSyncConfig; + return _cachedConfig; + } catch { + _cachedConfig = null; + return null; + } +} + +/** Reset config cache (for testing). */ +export function _resetConfigCache(): void { + _cachedConfig = undefined; +} + +function resolveRepo(basePath: string): string | null { + const result = ghDetectRepo(basePath); + return result.ok ? result.data! : null; +} + +// ─── Commit Linking ───────────────────────────────────────────────────────── + +/** + * Look up the GitHub issue number for a task so the commit message + * can include `Resolves #N`. Called from git-service commit building. + */ +export function getTaskIssueNumberForCommit( + basePath: string, + mid: string, + sid: string, + tid: string, +): number | null { + try { + const config = loadGitHubSyncConfig(basePath); + if (!config?.enabled) return null; + if (config.auto_link_commits === false) return null; + + const mapping = loadSyncMapping(basePath); + if (!mapping) return null; + + const record = getTaskRecord(mapping, mid, sid, tid); + return record?.issueNumber ?? null; + } catch { + return null; + } +} diff --git a/src/resources/extensions/github-sync/templates.ts b/src/resources/extensions/github-sync/templates.ts new file mode 100644 index 000000000..d6398716a --- /dev/null +++ b/src/resources/extensions/github-sync/templates.ts @@ -0,0 +1,183 @@ +/** + * Markdown formatters for GitHub issue bodies, PR descriptions, + * and summary comments. + * + * All functions produce GitHub-flavored markdown strings ready + * for the `gh` CLI body parameters. + */ + +// ─── Milestone Issue Body ─────────────────────────────────────────────────── + +export interface MilestoneData { + id: string; + title: string; + vision?: string; + successCriteria?: string[]; + slices?: Array<{ id: string; title: string; taskCount?: number }>; +} + +export function formatMilestoneIssueBody(data: MilestoneData): string { + const lines: string[] = []; + + lines.push(`# ${data.id}: ${data.title}`); + lines.push(""); + + if (data.vision) { + lines.push("## Vision"); + lines.push(data.vision); + lines.push(""); + } + + if (data.successCriteria?.length) { + lines.push("## Success Criteria"); + for (const criterion of data.successCriteria) { + lines.push(`- [ ] ${criterion}`); + } + lines.push(""); + } + + if (data.slices?.length) { + lines.push("## Slices"); + lines.push(""); + lines.push("| Slice | Title | Tasks |"); + lines.push("|-------|-------|-------|"); + for (const slice of data.slices) { + lines.push(`| ${slice.id} | ${slice.title} | ${slice.taskCount ?? "—"} |`); + } + lines.push(""); + } + + lines.push("---"); + lines.push("*Auto-generated by GSD GitHub Sync*"); + + return lines.join("\n"); +} + +// ─── Slice PR Body ────────────────────────────────────────────────────────── + +export interface SliceData { + id: string; + title: string; + goal?: string; + mustHaves?: string[]; + demoCriterion?: string; + tasks?: Array<{ id: string; title: string; issueNumber?: number }>; +} + +export function formatSlicePRBody(data: SliceData): string { + const lines: string[] = []; + + lines.push(`## ${data.id}: ${data.title}`); + lines.push(""); + + if (data.goal) { + lines.push("### Goal"); + lines.push(data.goal); + lines.push(""); + } + + if (data.mustHaves?.length) { + lines.push("### Must-Haves"); + for (const item of data.mustHaves) { + lines.push(`- ${item}`); + } + lines.push(""); + } + + if (data.demoCriterion) { + lines.push("### Demo Criterion"); + lines.push(data.demoCriterion); + lines.push(""); + } + + if (data.tasks?.length) { + lines.push("### Tasks"); + for (const task of data.tasks) { + const ref = task.issueNumber ? ` (#${task.issueNumber})` : ""; + lines.push(`- [ ] ${task.id}: ${task.title}${ref}`); + } + lines.push(""); + } + + lines.push("---"); + lines.push("*Auto-generated by GSD GitHub Sync*"); + + return lines.join("\n"); +} + +// ─── Task Issue Body ──────────────────────────────────────────────────────── + +export interface TaskData { + id: string; + title: string; + description?: string; + files?: string[]; + verifyCriteria?: string[]; +} + +export function formatTaskIssueBody(data: TaskData): string { + const lines: string[] = []; + + lines.push(`## ${data.id}: ${data.title}`); + lines.push(""); + + if (data.description) { + lines.push(data.description); + lines.push(""); + } + + if (data.files?.length) { + lines.push("### Files"); + for (const file of data.files) { + lines.push(`- \`${file}\``); + } + lines.push(""); + } + + if (data.verifyCriteria?.length) { + lines.push("### Verification"); + for (const criterion of data.verifyCriteria) { + lines.push(`- [ ] ${criterion}`); + } + lines.push(""); + } + + return lines.join("\n"); +} + +// ─── Summary Comment ──────────────────────────────────────────────────────── + +export interface SummaryData { + oneLiner?: string; + body?: string; + frontmatter?: Record; +} + +export function formatSummaryComment(data: SummaryData): string { + const lines: string[] = []; + + if (data.oneLiner) { + lines.push(`**Summary:** ${data.oneLiner}`); + lines.push(""); + } + + if (data.body) { + lines.push(data.body); + lines.push(""); + } + + if (data.frontmatter && Object.keys(data.frontmatter).length > 0) { + lines.push("
"); + lines.push("Metadata"); + lines.push(""); + lines.push("```yaml"); + for (const [key, value] of Object.entries(data.frontmatter)) { + lines.push(`${key}: ${JSON.stringify(value)}`); + } + lines.push("```"); + lines.push(""); + lines.push("
"); + } + + return lines.join("\n"); +} diff --git a/src/resources/extensions/github-sync/tests/cli.test.ts b/src/resources/extensions/github-sync/tests/cli.test.ts new file mode 100644 index 000000000..394f3529d --- /dev/null +++ b/src/resources/extensions/github-sync/tests/cli.test.ts @@ -0,0 +1,20 @@ +import test, { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { ghIsAvailable, _resetGhCache } from "../cli.ts"; + +describe("cli", () => { + beforeEach(() => { + _resetGhCache(); + }); + + it("ghIsAvailable returns boolean", () => { + const result = ghIsAvailable(); + assert.equal(typeof result, "boolean"); + }); + + it("ghIsAvailable caches result", () => { + const first = ghIsAvailable(); + const second = ghIsAvailable(); + assert.equal(first, second); + }); +}); diff --git a/src/resources/extensions/github-sync/tests/commit-linking.test.ts b/src/resources/extensions/github-sync/tests/commit-linking.test.ts new file mode 100644 index 000000000..60dc2f0b5 --- /dev/null +++ b/src/resources/extensions/github-sync/tests/commit-linking.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { buildTaskCommitMessage } from "../../gsd/git-service.ts"; + +describe("commit linking", () => { + it("appends Resolves #N when issueNumber is set", () => { + const msg = buildTaskCommitMessage({ + taskId: "S01/T02", + taskTitle: "implement auth", + issueNumber: 43, + }); + assert.ok(msg.includes("Resolves #43"), "should include Resolves trailer"); + assert.ok(msg.startsWith("feat(S01/T02):"), "subject line unchanged"); + }); + + it("includes both key files and Resolves #N", () => { + const msg = buildTaskCommitMessage({ + taskId: "S01/T02", + taskTitle: "implement auth", + keyFiles: ["src/auth.ts"], + issueNumber: 43, + }); + assert.ok(msg.includes("- src/auth.ts"), "key files present"); + assert.ok(msg.includes("Resolves #43"), "Resolves trailer present"); + // Resolves should come after key files + const keyFilesIdx = msg.indexOf("- src/auth.ts"); + const resolvesIdx = msg.indexOf("Resolves #43"); + assert.ok(resolvesIdx > keyFilesIdx, "Resolves after key files"); + }); + + it("no Resolves trailer when issueNumber is not set", () => { + const msg = buildTaskCommitMessage({ + taskId: "S01/T02", + taskTitle: "implement auth", + }); + assert.ok(!msg.includes("Resolves"), "no Resolves when no issueNumber"); + assert.ok(!msg.includes("\n"), "no body when no issueNumber or keyFiles"); + }); +}); diff --git a/src/resources/extensions/github-sync/tests/mapping.test.ts b/src/resources/extensions/github-sync/tests/mapping.test.ts new file mode 100644 index 000000000..cb467aeaa --- /dev/null +++ b/src/resources/extensions/github-sync/tests/mapping.test.ts @@ -0,0 +1,104 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + loadSyncMapping, + saveSyncMapping, + createEmptyMapping, + getMilestoneRecord, + getSliceRecord, + getTaskRecord, + getTaskIssueNumber, + setMilestoneRecord, + setSliceRecord, + setTaskRecord, +} from "../mapping.ts"; +import type { SyncMapping, MilestoneSyncRecord, SliceSyncRecord, SyncEntityRecord } from "../types.ts"; + +describe("mapping", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "gsd-sync-test-")); + mkdirSync(join(tmpDir, ".gsd"), { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("loadSyncMapping returns null when no file exists", () => { + const result = loadSyncMapping(tmpDir); + assert.equal(result, null); + }); + + it("round-trips save/load", () => { + const mapping = createEmptyMapping("owner/repo"); + saveSyncMapping(tmpDir, mapping); + const loaded = loadSyncMapping(tmpDir); + assert.deepEqual(loaded, mapping); + }); + + it("createEmptyMapping has correct structure", () => { + const mapping = createEmptyMapping("owner/repo"); + assert.equal(mapping.version, 1); + assert.equal(mapping.repo, "owner/repo"); + assert.deepEqual(mapping.milestones, {}); + assert.deepEqual(mapping.slices, {}); + assert.deepEqual(mapping.tasks, {}); + }); + + it("milestone record accessors work", () => { + const mapping = createEmptyMapping("owner/repo"); + assert.equal(getMilestoneRecord(mapping, "M001"), null); + + const record: MilestoneSyncRecord = { + issueNumber: 42, + ghMilestoneNumber: 1, + lastSyncedAt: "2025-01-01T00:00:00Z", + state: "open", + }; + setMilestoneRecord(mapping, "M001", record); + assert.deepEqual(getMilestoneRecord(mapping, "M001"), record); + }); + + it("slice record accessors work", () => { + const mapping = createEmptyMapping("owner/repo"); + assert.equal(getSliceRecord(mapping, "M001", "S01"), null); + + const record: SliceSyncRecord = { + issueNumber: 0, + prNumber: 50, + branch: "milestone/M001/S01", + lastSyncedAt: "2025-01-01T00:00:00Z", + state: "open", + }; + setSliceRecord(mapping, "M001", "S01", record); + assert.deepEqual(getSliceRecord(mapping, "M001", "S01"), record); + }); + + it("task record accessors work", () => { + const mapping = createEmptyMapping("owner/repo"); + assert.equal(getTaskRecord(mapping, "M001", "S01", "T01"), null); + assert.equal(getTaskIssueNumber(mapping, "M001", "S01", "T01"), null); + + const record: SyncEntityRecord = { + issueNumber: 43, + lastSyncedAt: "2025-01-01T00:00:00Z", + state: "open", + }; + setTaskRecord(mapping, "M001", "S01", "T01", record); + assert.deepEqual(getTaskRecord(mapping, "M001", "S01", "T01"), record); + assert.equal(getTaskIssueNumber(mapping, "M001", "S01", "T01"), 43); + }); + + it("rejects mapping with wrong version", () => { + const mapping = createEmptyMapping("owner/repo"); + (mapping as any).version = 2; + saveSyncMapping(tmpDir, mapping); + const loaded = loadSyncMapping(tmpDir); + assert.equal(loaded, null); + }); +}); diff --git a/src/resources/extensions/github-sync/tests/templates.test.ts b/src/resources/extensions/github-sync/tests/templates.test.ts new file mode 100644 index 000000000..2922a963b --- /dev/null +++ b/src/resources/extensions/github-sync/tests/templates.test.ts @@ -0,0 +1,110 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + formatMilestoneIssueBody, + formatSlicePRBody, + formatTaskIssueBody, + formatSummaryComment, +} from "../templates.ts"; + +describe("templates", () => { + describe("formatMilestoneIssueBody", () => { + it("includes title and vision", () => { + const body = formatMilestoneIssueBody({ + id: "M001", + title: "Build Auth", + vision: "Secure authentication for all users", + }); + assert.ok(body.includes("M001: Build Auth")); + assert.ok(body.includes("Secure authentication")); + }); + + it("renders success criteria as checkboxes", () => { + const body = formatMilestoneIssueBody({ + id: "M001", + title: "Auth", + successCriteria: ["Users can log in", "OAuth works"], + }); + assert.ok(body.includes("- [ ] Users can log in")); + assert.ok(body.includes("- [ ] OAuth works")); + }); + + it("renders slice table", () => { + const body = formatMilestoneIssueBody({ + id: "M001", + title: "Auth", + slices: [ + { id: "S01", title: "Core types", taskCount: 3 }, + { id: "S02", title: "OAuth", taskCount: 5 }, + ], + }); + assert.ok(body.includes("| S01 | Core types | 3 |")); + assert.ok(body.includes("| S02 | OAuth | 5 |")); + }); + }); + + describe("formatSlicePRBody", () => { + it("includes goal and must-haves", () => { + const body = formatSlicePRBody({ + id: "S01", + title: "Core Auth Types", + goal: "Define all auth types", + mustHaves: ["User type", "Session type"], + }); + assert.ok(body.includes("Define all auth types")); + assert.ok(body.includes("- User type")); + assert.ok(body.includes("- Session type")); + }); + + it("renders task checklist with issue links", () => { + const body = formatSlicePRBody({ + id: "S01", + title: "Auth", + tasks: [ + { id: "T01", title: "Types", issueNumber: 43 }, + { id: "T02", title: "Schema" }, + ], + }); + assert.ok(body.includes("- [ ] T01: Types (#43)")); + assert.ok(body.includes("- [ ] T02: Schema")); + assert.ok(!body.includes("T02: Schema (#")); + }); + }); + + describe("formatTaskIssueBody", () => { + it("includes files and verification", () => { + const body = formatTaskIssueBody({ + id: "T01", + title: "Add types", + files: ["src/types.ts"], + verifyCriteria: ["Types compile"], + }); + assert.ok(body.includes("`src/types.ts`")); + assert.ok(body.includes("- [ ] Types compile")); + }); + }); + + describe("formatSummaryComment", () => { + it("includes one-liner and body", () => { + const comment = formatSummaryComment({ + oneLiner: "Added retry logic", + body: "Implemented exponential backoff", + }); + assert.ok(comment.includes("**Summary:** Added retry logic")); + assert.ok(comment.includes("Implemented exponential backoff")); + }); + + it("wraps frontmatter in details block", () => { + const comment = formatSummaryComment({ + frontmatter: { duration: "45m", key_files: ["a.ts"] }, + }); + assert.ok(comment.includes("
")); + assert.ok(comment.includes("duration:")); + }); + + it("handles empty data gracefully", () => { + const comment = formatSummaryComment({}); + assert.equal(typeof comment, "string"); + }); + }); +}); diff --git a/src/resources/extensions/github-sync/types.ts b/src/resources/extensions/github-sync/types.ts new file mode 100644 index 000000000..a4d8882e6 --- /dev/null +++ b/src/resources/extensions/github-sync/types.ts @@ -0,0 +1,47 @@ +/** + * Type definitions for the GitHub Sync extension. + * + * Config shape (stored in GSD preferences under `github` key) and + * sync mapping records (stored in `.gsd/github-sync.json`). + */ + +// ─── Configuration ────────────────────────────────────────────────────────── + +export interface GitHubSyncConfig { + enabled: boolean; + /** "owner/repo" — auto-detected from git remote if omitted. */ + repo?: string; + /** GitHub Projects v2 number (optional). */ + project?: number; + /** Labels applied to all created issues. */ + labels?: string[]; + /** Append "Resolves #N" to task commits. Default: true. */ + auto_link_commits?: boolean; + /** Create per-slice draft PRs. Default: true. */ + slice_prs?: boolean; +} + +// ─── Sync Mapping ─────────────────────────────────────────────────────────── + +export interface SyncEntityRecord { + issueNumber: number; + lastSyncedAt: string; + state: "open" | "closed"; +} + +export interface MilestoneSyncRecord extends SyncEntityRecord { + ghMilestoneNumber: number; +} + +export interface SliceSyncRecord extends SyncEntityRecord { + prNumber: number; + branch: string; +} + +export interface SyncMapping { + version: 1; + repo: string; + milestones: Record; + slices: Record; + tasks: Record; +} diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index c569c6a8c..7c8743e5c 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -121,11 +121,21 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV const summaryContent = await loadFile(summaryPath); if (summaryContent) { const summary = parseSummary(summaryContent); + // Look up GitHub issue number for commit linking + let ghIssueNumber: number | undefined; + try { + const { getTaskIssueNumberForCommit } = await import("../github-sync/sync.js"); + ghIssueNumber = getTaskIssueNumberForCommit(s.basePath, mid, sid, tid) ?? undefined; + } catch { + // GitHub sync not available — skip + } + taskContext = { taskId: `${sid}/${tid}`, taskTitle: summary.title?.replace(/^T\d+:\s*/, "") || tid, oneLiner: summary.oneLiner || undefined, keyFiles: summary.frontmatter.key_files?.filter(f => !f.includes("{{")) || undefined, + issueNumber: ghIssueNumber, }; } } catch (e) { @@ -143,6 +153,14 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV debugLog("postUnit", { phase: "auto-commit", error: String(e) }); } + // GitHub sync (non-blocking, opt-in) + try { + const { runGitHubSync } = await import("../github-sync/sync.js"); + await runGitHubSync(s.basePath, s.currentUnit.type, s.currentUnit.id); + } catch (e) { + debugLog("postUnit", { phase: "github-sync", error: String(e) }); + } + // Doctor: fix mechanical bookkeeping (skipped for lightweight sidecars) if (!opts?.skipDoctor) try { const scopeParts = s.currentUnit.id.split("/").slice(0, 2); diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 7db9b0e70..d71f148c5 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -95,6 +95,8 @@ export interface TaskCommitContext { oneLiner?: string; /** Files modified by this task (from task summary frontmatter) */ keyFiles?: string[]; + /** GitHub issue number — appends "Resolves #N" trailer when set. */ + issueNumber?: number; } /** @@ -118,12 +120,22 @@ export function buildTaskCommitMessage(ctx: TaskCommitContext): string { const subject = `${type}(${scope}): ${truncated}`; // Build body with key files if available + const bodyParts: string[] = []; + if (ctx.keyFiles && ctx.keyFiles.length > 0) { const fileLines = ctx.keyFiles .slice(0, 8) // cap at 8 files to keep commit concise .map(f => `- ${f}`) .join("\n"); - return `${subject}\n\n${fileLines}`; + bodyParts.push(fileLines); + } + + if (ctx.issueNumber) { + bodyParts.push(`Resolves #${ctx.issueNumber}`); + } + + if (bodyParts.length > 0) { + return `${subject}\n\n${bodyParts.join("\n\n")}`; } return subject; diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index 60f041989..e14ca4a03 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -20,6 +20,7 @@ import type { ReactiveExecutionConfig, } from "./types.js"; import type { DynamicRoutingConfig } from "./model-router.js"; +import type { GitHubSyncConfig } from "../github-sync/types.js"; // ─── Workflow Modes ────────────────────────────────────────────────────────── @@ -86,6 +87,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ "context_selection", "widget_mode", "reactive_execution", + "github", ]); /** Canonical list of all dispatch unit types. */ @@ -215,6 +217,8 @@ export interface GSDPreferences { widget_mode?: "full" | "small" | "min" | "off"; /** Reactive (graph-derived parallel) task execution within slices. Disabled by default. */ reactive_execution?: ReactiveExecutionConfig; + /** GitHub sync configuration. Opt-in: syncs GSD events to GitHub Issues, Milestones, and PRs. */ + github?: GitHubSyncConfig; } export interface LoadedGSDPreferences { diff --git a/src/resources/extensions/gsd/preferences-validation.ts b/src/resources/extensions/gsd/preferences-validation.ts index 8f6a2ebcd..ac3ac95d8 100644 --- a/src/resources/extensions/gsd/preferences-validation.ts +++ b/src/resources/extensions/gsd/preferences-validation.ts @@ -696,5 +696,55 @@ export function validatePreferences(preferences: GSDPreferences): { } } + // ─── GitHub Sync ──────────────────────────────────────────────────────── + if (preferences.github !== undefined) { + if (typeof preferences.github === "object" && preferences.github !== null) { + const gh = preferences.github as unknown as Record; + const validGh: Record = {}; + + if (gh.enabled !== undefined) { + if (typeof gh.enabled === "boolean") validGh.enabled = gh.enabled; + else errors.push("github.enabled must be a boolean"); + } + if (gh.repo !== undefined) { + if (typeof gh.repo === "string" && gh.repo.includes("/")) validGh.repo = gh.repo; + else errors.push('github.repo must be a string in "owner/repo" format'); + } + if (gh.project !== undefined) { + const p = typeof gh.project === "number" ? gh.project : Number(gh.project); + if (Number.isFinite(p) && p > 0) validGh.project = Math.floor(p); + else errors.push("github.project must be a positive number"); + } + if (gh.labels !== undefined) { + if (Array.isArray(gh.labels) && gh.labels.every((l: unknown) => typeof l === "string")) { + validGh.labels = gh.labels; + } else { + errors.push("github.labels must be an array of strings"); + } + } + if (gh.auto_link_commits !== undefined) { + if (typeof gh.auto_link_commits === "boolean") validGh.auto_link_commits = gh.auto_link_commits; + else errors.push("github.auto_link_commits must be a boolean"); + } + if (gh.slice_prs !== undefined) { + if (typeof gh.slice_prs === "boolean") validGh.slice_prs = gh.slice_prs; + else errors.push("github.slice_prs must be a boolean"); + } + + const knownGhKeys = new Set(["enabled", "repo", "project", "labels", "auto_link_commits", "slice_prs"]); + for (const key of Object.keys(gh)) { + if (!knownGhKeys.has(key)) { + warnings.push(`unknown github key "${key}" — ignored`); + } + } + + if (Object.keys(validGh).length > 0) { + validated.github = validGh as unknown as import("../github-sync/types.js").GitHubSyncConfig; + } + } else { + errors.push("github must be an object"); + } + } + return { preferences: validated, errors, warnings }; } diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index bd3e88fb8..603af39e1 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -271,6 +271,9 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr context_selection: override.context_selection ?? base.context_selection, auto_visualize: override.auto_visualize ?? base.auto_visualize, auto_report: override.auto_report ?? base.auto_report, + github: (base.github || override.github) + ? { ...(base.github ?? {}), ...(override.github ?? {}) } as import("../github-sync/types.js").GitHubSyncConfig + : undefined, }; }