feat(gsd): GitHub sync extension — auto-sync to Issues, PRs, Milestones (#1603)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-20 10:10:37 -06:00 committed by GitHub
parent 485003777f
commit 7c25036ed9
15 changed files with 1685 additions and 1 deletions

View file

@ -0,0 +1,364 @@
/**
* Thin wrapper around the `gh` CLI.
*
* Every public function returns `GhResult<T>` never throws.
* Uses `execFileSync` (not `execSync`) for safety.
*/
import { execFileSync } from "node:child_process";
// ─── Result Type ────────────────────────────────────────────────────────────
export interface GhResult<T> {
ok: boolean;
data?: T;
error?: string;
}
function ok<T>(data: T): GhResult<T> {
return { ok: true, data };
}
function fail<T>(error: string): GhResult<T> {
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<string> {
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<T>(args: string[], cwd: string): GhResult<T> {
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<string> {
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<number> {
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<void> {
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<void> {
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<void> {
// 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<void>;
}
// ─── Milestones ─────────────────────────────────────────────────────────────
export function ghCreateMilestone(cwd: string, repo: string, title: string, description: string): GhResult<number> {
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<void> {
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<number> {
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<void> {
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<void> {
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<void> {
// 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<void>;
}
// ─── Branch Operations ──────────────────────────────────────────────────────
export function ghPushBranch(cwd: string, branch: string, setUpstream = true): GhResult<void> {
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<void> {
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));
}
}

View file

@ -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");
}
}

View file

@ -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;
}

View file

@ -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<void> {
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<void> {
// 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<void> {
// 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<void> {
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<string, unknown>,
});
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<void> {
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<string, unknown>,
});
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<void> {
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<string, unknown>)?.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;
}
}

View file

@ -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<string, unknown>;
}
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("<details>");
lines.push("<summary>Metadata</summary>");
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("</details>");
}
return lines.join("\n");
}

View file

@ -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);
});
});

View file

@ -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");
});
});

View file

@ -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);
});
});

View file

@ -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("<details>"));
assert.ok(comment.includes("duration:"));
});
it("handles empty data gracefully", () => {
const comment = formatSummaryComment({});
assert.equal(typeof comment, "string");
});
});
});

View file

@ -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<string, MilestoneSyncRecord>;
slices: Record<string, SliceSyncRecord>;
tasks: Record<string, SyncEntityRecord>;
}

View file

@ -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);

View file

@ -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;

View file

@ -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<string>([
"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 {

View file

@ -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<string, unknown>;
const validGh: Record<string, unknown> = {};
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 };
}

View file

@ -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,
};
}