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:
parent
485003777f
commit
7c25036ed9
15 changed files with 1685 additions and 1 deletions
364
src/resources/extensions/github-sync/cli.ts
Normal file
364
src/resources/extensions/github-sync/cli.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
93
src/resources/extensions/github-sync/index.ts
Normal file
93
src/resources/extensions/github-sync/index.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
81
src/resources/extensions/github-sync/mapping.ts
Normal file
81
src/resources/extensions/github-sync/mapping.ts
Normal 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;
|
||||
}
|
||||
556
src/resources/extensions/github-sync/sync.ts
Normal file
556
src/resources/extensions/github-sync/sync.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
183
src/resources/extensions/github-sync/templates.ts
Normal file
183
src/resources/extensions/github-sync/templates.ts
Normal 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");
|
||||
}
|
||||
20
src/resources/extensions/github-sync/tests/cli.test.ts
Normal file
20
src/resources/extensions/github-sync/tests/cli.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
104
src/resources/extensions/github-sync/tests/mapping.test.ts
Normal file
104
src/resources/extensions/github-sync/tests/mapping.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
110
src/resources/extensions/github-sync/tests/templates.test.ts
Normal file
110
src/resources/extensions/github-sync/tests/templates.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
47
src/resources/extensions/github-sync/types.ts
Normal file
47
src/resources/extensions/github-sync/types.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue