specweave 1.0.235 → 1.0.239
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -193
- package/dist/plugins/specweave-github/lib/github-ac-comment-poster.d.ts +37 -0
- package/dist/plugins/specweave-github/lib/github-ac-comment-poster.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-ac-comment-poster.js +176 -0
- package/dist/plugins/specweave-github/lib/github-ac-comment-poster.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-batch-sync.d.ts +36 -0
- package/dist/plugins/specweave-github/lib/github-batch-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-batch-sync.js +115 -0
- package/dist/plugins/specweave-github/lib/github-batch-sync.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-board-resolver-v2.d.ts +37 -0
- package/dist/plugins/specweave-github/lib/github-board-resolver-v2.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-board-resolver-v2.js +56 -0
- package/dist/plugins/specweave-github/lib/github-board-resolver-v2.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-conflict-resolver.d.ts +68 -0
- package/dist/plugins/specweave-github/lib/github-conflict-resolver.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-conflict-resolver.js +102 -0
- package/dist/plugins/specweave-github/lib/github-conflict-resolver.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-cross-repo-sync.d.ts +64 -0
- package/dist/plugins/specweave-github/lib/github-cross-repo-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-cross-repo-sync.js +162 -0
- package/dist/plugins/specweave-github/lib/github-cross-repo-sync.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-field-sync.d.ts +50 -0
- package/dist/plugins/specweave-github/lib/github-field-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-field-sync.js +107 -0
- package/dist/plugins/specweave-github/lib/github-field-sync.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-graphql-client.d.ts +53 -0
- package/dist/plugins/specweave-github/lib/github-graphql-client.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-graphql-client.js +138 -0
- package/dist/plugins/specweave-github/lib/github-graphql-client.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-issue-body-generator.d.ts +40 -0
- package/dist/plugins/specweave-github/lib/github-issue-body-generator.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-issue-body-generator.js +50 -0
- package/dist/plugins/specweave-github/lib/github-issue-body-generator.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-issue-body-parser.d.ts +30 -0
- package/dist/plugins/specweave-github/lib/github-issue-body-parser.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-issue-body-parser.js +75 -0
- package/dist/plugins/specweave-github/lib/github-issue-body-parser.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-pull-sync.d.ts +94 -0
- package/dist/plugins/specweave-github/lib/github-pull-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-pull-sync.js +232 -0
- package/dist/plugins/specweave-github/lib/github-pull-sync.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-push-sync.d.ts +50 -0
- package/dist/plugins/specweave-github/lib/github-push-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-push-sync.js +114 -0
- package/dist/plugins/specweave-github/lib/github-push-sync.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-rate-limiter.d.ts +53 -0
- package/dist/plugins/specweave-github/lib/github-rate-limiter.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-rate-limiter.js +109 -0
- package/dist/plugins/specweave-github/lib/github-rate-limiter.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-spec-frontmatter-updater.d.ts +21 -0
- package/dist/plugins/specweave-github/lib/github-spec-frontmatter-updater.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-spec-frontmatter-updater.js +161 -0
- package/dist/plugins/specweave-github/lib/github-spec-frontmatter-updater.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-sync-orchestrator.d.ts +46 -0
- package/dist/plugins/specweave-github/lib/github-sync-orchestrator.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-sync-orchestrator.js +99 -0
- package/dist/plugins/specweave-github/lib/github-sync-orchestrator.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-us-auto-closer.d.ts +43 -0
- package/dist/plugins/specweave-github/lib/github-us-auto-closer.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-us-auto-closer.js +153 -0
- package/dist/plugins/specweave-github/lib/github-us-auto-closer.js.map +1 -0
- package/dist/plugins/specweave-github/lib/index.d.ts +1 -4
- package/dist/plugins/specweave-github/lib/index.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/index.js +1 -4
- package/dist/plugins/specweave-github/lib/index.js.map +1 -1
- package/dist/plugins/specweave-testing/lib/playwright-ci-defaults.d.ts +7 -0
- package/dist/plugins/specweave-testing/lib/playwright-ci-defaults.d.ts.map +1 -0
- package/dist/plugins/specweave-testing/lib/playwright-ci-defaults.js +15 -0
- package/dist/plugins/specweave-testing/lib/playwright-ci-defaults.js.map +1 -0
- package/dist/plugins/specweave-testing/lib/playwright-cli-detector.d.ts +10 -0
- package/dist/plugins/specweave-testing/lib/playwright-cli-detector.d.ts.map +1 -0
- package/dist/plugins/specweave-testing/lib/playwright-cli-detector.js +36 -0
- package/dist/plugins/specweave-testing/lib/playwright-cli-detector.js.map +1 -0
- package/dist/plugins/specweave-testing/lib/playwright-cli-runner.d.ts +25 -0
- package/dist/plugins/specweave-testing/lib/playwright-cli-runner.d.ts.map +1 -0
- package/dist/plugins/specweave-testing/lib/playwright-cli-runner.js +57 -0
- package/dist/plugins/specweave-testing/lib/playwright-cli-runner.js.map +1 -0
- package/dist/plugins/specweave-testing/lib/playwright-routing.d.ts +7 -0
- package/dist/plugins/specweave-testing/lib/playwright-routing.d.ts.map +1 -0
- package/dist/plugins/specweave-testing/lib/playwright-routing.js +17 -0
- package/dist/plugins/specweave-testing/lib/playwright-routing.js.map +1 -0
- package/dist/src/cli/commands/auto.d.ts.map +1 -1
- package/dist/src/cli/commands/auto.js +1 -2
- package/dist/src/cli/commands/auto.js.map +1 -1
- package/dist/src/cli/commands/cancel-auto.js +1 -2
- package/dist/src/cli/commands/cancel-auto.js.map +1 -1
- package/dist/src/cli/commands/living-docs.js +2 -2
- package/dist/src/cli/commands/living-docs.js.map +1 -1
- package/dist/src/cli/commands/update.d.ts.map +1 -1
- package/dist/src/cli/commands/update.js +1 -2
- package/dist/src/cli/commands/update.js.map +1 -1
- package/dist/src/core/config/types.d.ts +8 -0
- package/dist/src/core/config/types.d.ts.map +1 -1
- package/dist/src/core/config/types.js +3 -0
- package/dist/src/core/config/types.js.map +1 -1
- package/dist/src/core/types/sync-profile.d.ts +72 -0
- package/dist/src/core/types/sync-profile.d.ts.map +1 -1
- package/dist/src/core/types/sync-profile.js +6 -0
- package/dist/src/core/types/sync-profile.js.map +1 -1
- package/package.json +2 -2
- package/plugins/specweave/hooks/hooks.json +2 -2
- package/plugins/specweave/hooks/startup-health-check.sh +1 -1
- package/plugins/specweave/hooks/stop-auto-v5.sh +166 -0
- package/plugins/specweave/hooks/user-prompt-submit.sh +10 -0
- package/plugins/specweave/hooks/v2/dispatchers/post-tool-use.sh +21 -1
- package/plugins/specweave/hooks/v2/dispatchers/session-start.sh +1 -1
- package/plugins/specweave/skills/auto/SKILL.md +71 -251
- package/plugins/specweave/skills/team-build/SKILL.md +370 -0
- package/plugins/specweave/skills/team-merge/SKILL.md +123 -0
- package/plugins/specweave/skills/team-orchestrate/SKILL.md +800 -0
- package/plugins/specweave/skills/team-status/SKILL.md +89 -0
- package/plugins/specweave-github/MULTI-PROJECT-SYNC-ARCHITECTURE.md +94 -8
- package/plugins/specweave-github/commands/sync.md +17 -3
- package/plugins/specweave-github/hooks/github-ac-sync-handler.sh +255 -0
- package/plugins/specweave-github/hooks/github-auto-create-handler.sh +455 -0
- package/plugins/specweave-github/lib/github-ac-comment-poster.js +150 -0
- package/plugins/specweave-github/lib/github-ac-comment-poster.ts +245 -0
- package/plugins/specweave-github/lib/github-batch-sync.js +93 -0
- package/plugins/specweave-github/lib/github-batch-sync.ts +152 -0
- package/plugins/specweave-github/lib/github-board-resolver-v2.js +47 -0
- package/plugins/specweave-github/lib/github-board-resolver-v2.ts +73 -0
- package/plugins/specweave-github/lib/github-conflict-resolver.js +90 -0
- package/plugins/specweave-github/lib/github-conflict-resolver.ts +154 -0
- package/plugins/specweave-github/lib/github-cross-repo-sync.js +168 -0
- package/plugins/specweave-github/lib/github-cross-repo-sync.ts +252 -0
- package/plugins/specweave-github/lib/github-field-sync.js +116 -0
- package/plugins/specweave-github/lib/github-field-sync.ts +165 -0
- package/plugins/specweave-github/lib/github-graphql-client.js +129 -0
- package/plugins/specweave-github/lib/github-graphql-client.ts +181 -0
- package/plugins/specweave-github/lib/github-issue-body-generator.js +30 -0
- package/plugins/specweave-github/lib/github-issue-body-generator.ts +76 -0
- package/plugins/specweave-github/lib/github-issue-body-parser.js +55 -0
- package/plugins/specweave-github/lib/github-issue-body-parser.ts +92 -0
- package/plugins/specweave-github/lib/github-pull-sync.js +185 -0
- package/plugins/specweave-github/lib/github-pull-sync.ts +343 -0
- package/plugins/specweave-github/lib/github-push-sync.js +119 -0
- package/plugins/specweave-github/lib/github-push-sync.ts +174 -0
- package/plugins/specweave-github/lib/github-rate-limiter.js +96 -0
- package/plugins/specweave-github/lib/github-rate-limiter.ts +143 -0
- package/plugins/specweave-github/lib/github-spec-frontmatter-updater.js +117 -0
- package/plugins/specweave-github/lib/github-spec-frontmatter-updater.ts +180 -0
- package/plugins/specweave-github/lib/github-sync-orchestrator.js +84 -0
- package/plugins/specweave-github/lib/github-sync-orchestrator.ts +156 -0
- package/plugins/specweave-github/lib/github-us-auto-closer.js +134 -0
- package/plugins/specweave-github/lib/github-us-auto-closer.ts +226 -0
- package/plugins/specweave-github/lib/index.js +1 -7
- package/plugins/specweave-github/lib/index.ts +1 -4
- package/plugins/specweave-github/skills/github-sync/SKILL.md +76 -4
- package/plugins/specweave-testing/commands/e2e-setup.md +18 -0
- package/plugins/specweave-testing/commands/ui-automate.md +2 -0
- package/plugins/specweave-testing/commands/ui-inspect.md +8 -0
- package/plugins/specweave-testing/lib/playwright-ci-defaults.d.ts +6 -0
- package/plugins/specweave-testing/lib/playwright-ci-defaults.js +14 -0
- package/plugins/specweave-testing/lib/playwright-ci-defaults.ts +24 -0
- package/plugins/specweave-testing/lib/playwright-cli-detector.js +33 -0
- package/plugins/specweave-testing/lib/playwright-cli-detector.ts +48 -0
- package/plugins/specweave-testing/lib/playwright-cli-runner.js +58 -0
- package/plugins/specweave-testing/lib/playwright-cli-runner.ts +80 -0
- package/plugins/specweave-testing/lib/playwright-routing.js +16 -0
- package/plugins/specweave-testing/lib/playwright-routing.ts +38 -0
- package/plugins/specweave-testing/skills/e2e-testing/SKILL.md +38 -0
- package/src/templates/CLAUDE.md.template +7 -0
- package/src/templates/config.json.template +9 -1
- package/dist/plugins/specweave-github/lib/subtask-sync.d.ts +0 -51
- package/dist/plugins/specweave-github/lib/subtask-sync.d.ts.map +0 -1
- package/dist/plugins/specweave-github/lib/subtask-sync.js +0 -147
- package/dist/plugins/specweave-github/lib/subtask-sync.js.map +0 -1
- package/dist/plugins/specweave-github/lib/task-parser.d.ts +0 -37
- package/dist/plugins/specweave-github/lib/task-parser.d.ts.map +0 -1
- package/dist/plugins/specweave-github/lib/task-parser.js +0 -211
- package/dist/plugins/specweave-github/lib/task-parser.js.map +0 -1
- package/dist/plugins/specweave-github/lib/task-sync.d.ts +0 -56
- package/dist/plugins/specweave-github/lib/task-sync.d.ts.map +0 -1
- package/dist/plugins/specweave-github/lib/task-sync.js +0 -375
- package/dist/plugins/specweave-github/lib/task-sync.js.map +0 -1
- package/plugins/specweave/hooks/validate-completion-conditions.sh +0 -474
- package/plugins/specweave-github/lib/subtask-sync.d.ts +0 -51
- package/plugins/specweave-github/lib/subtask-sync.d.ts.map +0 -1
- package/plugins/specweave-github/lib/subtask-sync.js +0 -154
- package/plugins/specweave-github/lib/subtask-sync.js.map +0 -1
- package/plugins/specweave-github/lib/subtask-sync.ts +0 -225
- package/plugins/specweave-github/lib/task-parser.d.js +0 -0
- package/plugins/specweave-github/lib/task-parser.d.ts +0 -37
- package/plugins/specweave-github/lib/task-parser.d.ts.map +0 -1
- package/plugins/specweave-github/lib/task-parser.js +0 -195
- package/plugins/specweave-github/lib/task-parser.js.map +0 -1
- package/plugins/specweave-github/lib/task-parser.ts +0 -246
- package/plugins/specweave-github/lib/task-sync.d.js +0 -0
- package/plugins/specweave-github/lib/task-sync.d.ts +0 -51
- package/plugins/specweave-github/lib/task-sync.d.ts.map +0 -1
- package/plugins/specweave-github/lib/task-sync.js +0 -415
- package/plugins/specweave-github/lib/task-sync.js.map +0 -1
- package/plugins/specweave-github/lib/task-sync.ts +0 -451
- package/plugins/specweave-github/skills/github-issue-tracker/SKILL.md +0 -496
- /package/plugins/specweave/hooks/{stop-auto.sh → _archive/stop-auto-v4-legacy.sh} +0 -0
- /package/plugins/{specweave-github/lib/subtask-sync.d.js → specweave-testing/lib/playwright-ci-defaults.d.js} +0 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { execFileNoThrow } from "../../../src/utils/execFileNoThrow.js";
|
|
2
|
+
import { generateIssueBody } from "./github-issue-body-generator.js";
|
|
3
|
+
async function pushSyncUserStories(userStories, options) {
|
|
4
|
+
const result = { created: [], updated: [], errors: [] };
|
|
5
|
+
if (options.dryRun) {
|
|
6
|
+
return result;
|
|
7
|
+
}
|
|
8
|
+
const env = getEnv(options.token);
|
|
9
|
+
const repoSlug = `${options.owner}/${options.repo}`;
|
|
10
|
+
for (const us of userStories) {
|
|
11
|
+
try {
|
|
12
|
+
const existing = await searchIssueByPrefix(us.id, repoSlug, env);
|
|
13
|
+
const body = generateIssueBody({
|
|
14
|
+
id: us.id,
|
|
15
|
+
title: us.title,
|
|
16
|
+
description: us.description,
|
|
17
|
+
priority: us.priority,
|
|
18
|
+
acceptanceCriteria: us.acceptanceCriteria,
|
|
19
|
+
specId: us.specId
|
|
20
|
+
});
|
|
21
|
+
const title = `[${us.id}] ${us.title}`;
|
|
22
|
+
if (existing) {
|
|
23
|
+
const updated = await updateIssue(existing.number, title, body, repoSlug, env);
|
|
24
|
+
result.updated.push({
|
|
25
|
+
userStoryId: us.id,
|
|
26
|
+
issueNumber: updated.number,
|
|
27
|
+
issueUrl: updated.url
|
|
28
|
+
});
|
|
29
|
+
} else {
|
|
30
|
+
const created = await createIssue(title, body, us, repoSlug, env);
|
|
31
|
+
result.created.push({
|
|
32
|
+
userStoryId: us.id,
|
|
33
|
+
issueNumber: created.number,
|
|
34
|
+
issueUrl: created.url,
|
|
35
|
+
issueNodeId: created.node_id
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
result.errors.push({
|
|
40
|
+
userStoryId: us.id,
|
|
41
|
+
error: err instanceof Error ? err.message : String(err)
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
async function searchIssueByPrefix(usId, repoSlug, env) {
|
|
48
|
+
const res = await execFileNoThrow("gh", [
|
|
49
|
+
"issue",
|
|
50
|
+
"list",
|
|
51
|
+
"--repo",
|
|
52
|
+
repoSlug,
|
|
53
|
+
"--search",
|
|
54
|
+
`[${usId}] in:title`,
|
|
55
|
+
"--json",
|
|
56
|
+
"number,title,node_id",
|
|
57
|
+
"--limit",
|
|
58
|
+
"1"
|
|
59
|
+
], { env });
|
|
60
|
+
if (!res.success) {
|
|
61
|
+
throw new Error(`Search failed: ${res.stderr}`);
|
|
62
|
+
}
|
|
63
|
+
const issues = JSON.parse(res.stdout);
|
|
64
|
+
return issues.length > 0 ? issues[0] : null;
|
|
65
|
+
}
|
|
66
|
+
async function createIssue(title, body, us, repoSlug, env) {
|
|
67
|
+
const args = [
|
|
68
|
+
"issue",
|
|
69
|
+
"create",
|
|
70
|
+
"--repo",
|
|
71
|
+
repoSlug,
|
|
72
|
+
"--title",
|
|
73
|
+
title,
|
|
74
|
+
"--body",
|
|
75
|
+
body,
|
|
76
|
+
"--label",
|
|
77
|
+
"user-story",
|
|
78
|
+
"--label",
|
|
79
|
+
`spec:${us.specId || "unknown"}`,
|
|
80
|
+
"--label",
|
|
81
|
+
`priority:${us.priority}`,
|
|
82
|
+
"--json",
|
|
83
|
+
"number,url,node_id"
|
|
84
|
+
];
|
|
85
|
+
const res = await execFileNoThrow("gh", args, { env });
|
|
86
|
+
if (!res.success) {
|
|
87
|
+
throw new Error(`Create failed: ${res.stderr}`);
|
|
88
|
+
}
|
|
89
|
+
return JSON.parse(res.stdout);
|
|
90
|
+
}
|
|
91
|
+
async function updateIssue(issueNumber, title, body, repoSlug, env) {
|
|
92
|
+
const args = [
|
|
93
|
+
"issue",
|
|
94
|
+
"edit",
|
|
95
|
+
String(issueNumber),
|
|
96
|
+
"--repo",
|
|
97
|
+
repoSlug,
|
|
98
|
+
"--title",
|
|
99
|
+
title,
|
|
100
|
+
"--body",
|
|
101
|
+
body,
|
|
102
|
+
"--json",
|
|
103
|
+
"number,url"
|
|
104
|
+
];
|
|
105
|
+
const res = await execFileNoThrow("gh", args, { env });
|
|
106
|
+
if (!res.success) {
|
|
107
|
+
throw new Error(`Update failed: ${res.stderr}`);
|
|
108
|
+
}
|
|
109
|
+
return JSON.parse(res.stdout);
|
|
110
|
+
}
|
|
111
|
+
function getEnv(token) {
|
|
112
|
+
if (token) {
|
|
113
|
+
return { ...process.env, GH_TOKEN: token };
|
|
114
|
+
}
|
|
115
|
+
return process.env;
|
|
116
|
+
}
|
|
117
|
+
export {
|
|
118
|
+
pushSyncUserStories
|
|
119
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Push Sync — User Stories → GitHub Issues
|
|
3
|
+
*
|
|
4
|
+
* Creates or updates GitHub issues from SpecWeave user stories.
|
|
5
|
+
* Uses `gh` CLI for all operations (no direct HTTP).
|
|
6
|
+
*
|
|
7
|
+
* @module github-push-sync
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileNoThrow } from '../../../src/utils/execFileNoThrow.js';
|
|
11
|
+
import { generateIssueBody } from './github-issue-body-generator.js';
|
|
12
|
+
|
|
13
|
+
export interface UserStoryForSync {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
description: string;
|
|
17
|
+
priority: string;
|
|
18
|
+
status: string;
|
|
19
|
+
acceptanceCriteria: Array<{ id: string; description: string; completed: boolean }>;
|
|
20
|
+
specId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PushSyncOptions {
|
|
24
|
+
owner: string;
|
|
25
|
+
repo: string;
|
|
26
|
+
token?: string;
|
|
27
|
+
dryRun?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PushSyncResult {
|
|
31
|
+
created: Array<{ userStoryId: string; issueNumber: number; issueUrl: string; issueNodeId: string }>;
|
|
32
|
+
updated: Array<{ userStoryId: string; issueNumber: number; issueUrl: string }>;
|
|
33
|
+
errors: Array<{ userStoryId: string; error: string }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Push sync user stories to GitHub issues.
|
|
38
|
+
* For each US: search by [US-XXX] prefix, create if new, update if existing.
|
|
39
|
+
*/
|
|
40
|
+
export async function pushSyncUserStories(
|
|
41
|
+
userStories: UserStoryForSync[],
|
|
42
|
+
options: PushSyncOptions,
|
|
43
|
+
): Promise<PushSyncResult> {
|
|
44
|
+
const result: PushSyncResult = { created: [], updated: [], errors: [] };
|
|
45
|
+
|
|
46
|
+
if (options.dryRun) {
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const env = getEnv(options.token);
|
|
51
|
+
const repoSlug = `${options.owner}/${options.repo}`;
|
|
52
|
+
|
|
53
|
+
for (const us of userStories) {
|
|
54
|
+
try {
|
|
55
|
+
const existing = await searchIssueByPrefix(us.id, repoSlug, env);
|
|
56
|
+
|
|
57
|
+
const body = generateIssueBody({
|
|
58
|
+
id: us.id,
|
|
59
|
+
title: us.title,
|
|
60
|
+
description: us.description,
|
|
61
|
+
priority: us.priority,
|
|
62
|
+
acceptanceCriteria: us.acceptanceCriteria,
|
|
63
|
+
specId: us.specId,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const title = `[${us.id}] ${us.title}`;
|
|
67
|
+
|
|
68
|
+
if (existing) {
|
|
69
|
+
// Update existing issue
|
|
70
|
+
const updated = await updateIssue(existing.number, title, body, repoSlug, env);
|
|
71
|
+
result.updated.push({
|
|
72
|
+
userStoryId: us.id,
|
|
73
|
+
issueNumber: updated.number,
|
|
74
|
+
issueUrl: updated.url,
|
|
75
|
+
});
|
|
76
|
+
} else {
|
|
77
|
+
// Create new issue
|
|
78
|
+
const created = await createIssue(title, body, us, repoSlug, env);
|
|
79
|
+
result.created.push({
|
|
80
|
+
userStoryId: us.id,
|
|
81
|
+
issueNumber: created.number,
|
|
82
|
+
issueUrl: created.url,
|
|
83
|
+
issueNodeId: created.node_id,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
result.errors.push({
|
|
88
|
+
userStoryId: us.id,
|
|
89
|
+
error: err instanceof Error ? err.message : String(err),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function searchIssueByPrefix(
|
|
98
|
+
usId: string,
|
|
99
|
+
repoSlug: string,
|
|
100
|
+
env: NodeJS.ProcessEnv,
|
|
101
|
+
): Promise<{ number: number; title: string; node_id: string } | null> {
|
|
102
|
+
const res = await execFileNoThrow('gh', [
|
|
103
|
+
'issue', 'list',
|
|
104
|
+
'--repo', repoSlug,
|
|
105
|
+
'--search', `[${usId}] in:title`,
|
|
106
|
+
'--json', 'number,title,node_id',
|
|
107
|
+
'--limit', '1',
|
|
108
|
+
], { env });
|
|
109
|
+
|
|
110
|
+
if (!res.success) {
|
|
111
|
+
throw new Error(`Search failed: ${res.stderr}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const issues = JSON.parse(res.stdout);
|
|
115
|
+
return issues.length > 0 ? issues[0] : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function createIssue(
|
|
119
|
+
title: string,
|
|
120
|
+
body: string,
|
|
121
|
+
us: UserStoryForSync,
|
|
122
|
+
repoSlug: string,
|
|
123
|
+
env: NodeJS.ProcessEnv,
|
|
124
|
+
): Promise<{ number: number; url: string; node_id: string }> {
|
|
125
|
+
const args = [
|
|
126
|
+
'issue', 'create',
|
|
127
|
+
'--repo', repoSlug,
|
|
128
|
+
'--title', title,
|
|
129
|
+
'--body', body,
|
|
130
|
+
'--label', 'user-story',
|
|
131
|
+
'--label', `spec:${us.specId || 'unknown'}`,
|
|
132
|
+
'--label', `priority:${us.priority}`,
|
|
133
|
+
'--json', 'number,url,node_id',
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
const res = await execFileNoThrow('gh', args, { env });
|
|
137
|
+
|
|
138
|
+
if (!res.success) {
|
|
139
|
+
throw new Error(`Create failed: ${res.stderr}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return JSON.parse(res.stdout);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function updateIssue(
|
|
146
|
+
issueNumber: number,
|
|
147
|
+
title: string,
|
|
148
|
+
body: string,
|
|
149
|
+
repoSlug: string,
|
|
150
|
+
env: NodeJS.ProcessEnv,
|
|
151
|
+
): Promise<{ number: number; url: string }> {
|
|
152
|
+
const args = [
|
|
153
|
+
'issue', 'edit', String(issueNumber),
|
|
154
|
+
'--repo', repoSlug,
|
|
155
|
+
'--title', title,
|
|
156
|
+
'--body', body,
|
|
157
|
+
'--json', 'number,url',
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const res = await execFileNoThrow('gh', args, { env });
|
|
161
|
+
|
|
162
|
+
if (!res.success) {
|
|
163
|
+
throw new Error(`Update failed: ${res.stderr}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return JSON.parse(res.stdout);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getEnv(token?: string): NodeJS.ProcessEnv {
|
|
170
|
+
if (token) {
|
|
171
|
+
return { ...process.env, GH_TOKEN: token };
|
|
172
|
+
}
|
|
173
|
+
return process.env;
|
|
174
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { execFileNoThrow } from "../../../src/utils/execFileNoThrow.js";
|
|
2
|
+
const CALLS_PER_USER_STORY = 3;
|
|
3
|
+
const CALLS_PER_SPEC_OVERHEAD = 2;
|
|
4
|
+
class GitHubRateLimiter {
|
|
5
|
+
constructor(token) {
|
|
6
|
+
this.totalUsed = 0;
|
|
7
|
+
this.knownLimit = 0;
|
|
8
|
+
this.token = token;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Query current GitHub API rate limit via `gh api rate_limit`.
|
|
12
|
+
*/
|
|
13
|
+
async checkRateLimit() {
|
|
14
|
+
const env = this.getEnv();
|
|
15
|
+
const result = await execFileNoThrow("gh", ["api", "rate_limit"], { env });
|
|
16
|
+
if (!result.success) {
|
|
17
|
+
throw new Error(`gh CLI failed: ${result.stderr || result.error?.message || "unknown error"}`);
|
|
18
|
+
}
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(result.stdout);
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error(`Failed to parse rate limit response: ${result.stdout.slice(0, 100)}`);
|
|
24
|
+
}
|
|
25
|
+
const core = parsed?.resources?.core;
|
|
26
|
+
if (!core || core.remaining === void 0 || core.limit === void 0 || core.reset === void 0) {
|
|
27
|
+
throw new Error("Rate limit response missing expected fields (resources.core)");
|
|
28
|
+
}
|
|
29
|
+
this.knownLimit = core.limit;
|
|
30
|
+
const percentUsed = core.limit > 0 ? (core.limit - core.remaining) / core.limit * 100 : 0;
|
|
31
|
+
return {
|
|
32
|
+
remaining: core.remaining,
|
|
33
|
+
limit: core.limit,
|
|
34
|
+
resetAt: new Date(core.reset * 1e3),
|
|
35
|
+
percentUsed
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Estimate how many API calls a sync operation will require.
|
|
40
|
+
*/
|
|
41
|
+
estimateApiCalls(specCount, userStoryCount) {
|
|
42
|
+
if (specCount === 0 && userStoryCount === 0) {
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
return specCount * CALLS_PER_SPEC_OVERHEAD + userStoryCount * CALLS_PER_USER_STORY;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Check whether a sync operation can proceed given the current rate limit.
|
|
49
|
+
*/
|
|
50
|
+
async canProceed(estimatedCalls) {
|
|
51
|
+
const status = await this.checkRateLimit();
|
|
52
|
+
if (estimatedCalls > status.remaining) {
|
|
53
|
+
const waitMs = Math.max(0, status.resetAt.getTime() - Date.now());
|
|
54
|
+
return {
|
|
55
|
+
allowed: false,
|
|
56
|
+
waitMs,
|
|
57
|
+
reason: `Rate limit: need ${estimatedCalls} calls but only ${status.remaining}/${status.limit} remaining. Resets in ${Math.ceil(waitMs / 1e3)}s`
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (status.percentUsed > 90) {
|
|
61
|
+
const waitMs = Math.max(0, status.resetAt.getTime() - Date.now());
|
|
62
|
+
return {
|
|
63
|
+
allowed: false,
|
|
64
|
+
waitMs,
|
|
65
|
+
reason: `Rate limit at ${status.percentUsed.toFixed(0)}% \u2014 too high to proceed safely`
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { allowed: true };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Record API calls made during a sync operation.
|
|
72
|
+
*/
|
|
73
|
+
recordUsage(calls) {
|
|
74
|
+
if (calls < 0) {
|
|
75
|
+
throw new Error("Cannot record negative usage");
|
|
76
|
+
}
|
|
77
|
+
this.totalUsed += calls;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Get the percentage of the known limit used by this limiter instance.
|
|
81
|
+
*/
|
|
82
|
+
getUsagePercent() {
|
|
83
|
+
if (this.knownLimit === 0) return 0;
|
|
84
|
+
const pct = this.totalUsed / this.knownLimit * 100;
|
|
85
|
+
return Math.min(pct, 100);
|
|
86
|
+
}
|
|
87
|
+
getEnv() {
|
|
88
|
+
if (this.token) {
|
|
89
|
+
return { ...process.env, GH_TOKEN: this.token };
|
|
90
|
+
}
|
|
91
|
+
return process.env;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export {
|
|
95
|
+
GitHubRateLimiter
|
|
96
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Rate Limiter
|
|
3
|
+
*
|
|
4
|
+
* Shared token-bucket rate limiter for multi-repo GitHub sync.
|
|
5
|
+
* Queries `gh api rate_limit`, tracks cumulative usage, and gates
|
|
6
|
+
* sync operations when approaching API limits.
|
|
7
|
+
*
|
|
8
|
+
* @module github-rate-limiter
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileNoThrow } from '../../../src/utils/execFileNoThrow.js';
|
|
12
|
+
|
|
13
|
+
export interface RateLimitStatus {
|
|
14
|
+
/** Remaining requests in current window */
|
|
15
|
+
remaining: number;
|
|
16
|
+
/** Total limit per window */
|
|
17
|
+
limit: number;
|
|
18
|
+
/** When the rate limit resets */
|
|
19
|
+
resetAt: Date;
|
|
20
|
+
/** Percentage of limit used (0-100) */
|
|
21
|
+
percentUsed: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CanProceedResult {
|
|
25
|
+
allowed: boolean;
|
|
26
|
+
waitMs?: number;
|
|
27
|
+
reason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Calls per user story: search + create/update + labels */
|
|
31
|
+
const CALLS_PER_USER_STORY = 3;
|
|
32
|
+
/** Overhead per spec: milestone check, project check */
|
|
33
|
+
const CALLS_PER_SPEC_OVERHEAD = 2;
|
|
34
|
+
|
|
35
|
+
export class GitHubRateLimiter {
|
|
36
|
+
private token?: string;
|
|
37
|
+
private totalUsed = 0;
|
|
38
|
+
private knownLimit = 0;
|
|
39
|
+
|
|
40
|
+
constructor(token?: string) {
|
|
41
|
+
this.token = token;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Query current GitHub API rate limit via `gh api rate_limit`.
|
|
46
|
+
*/
|
|
47
|
+
async checkRateLimit(): Promise<RateLimitStatus> {
|
|
48
|
+
const env = this.getEnv();
|
|
49
|
+
const result = await execFileNoThrow('gh', ['api', 'rate_limit'], { env });
|
|
50
|
+
|
|
51
|
+
if (!result.success) {
|
|
52
|
+
throw new Error(`gh CLI failed: ${result.stderr || result.error?.message || 'unknown error'}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let parsed: { resources?: { core?: { remaining?: number; limit?: number; reset?: number } } };
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(result.stdout);
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error(`Failed to parse rate limit response: ${result.stdout.slice(0, 100)}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const core = parsed?.resources?.core;
|
|
63
|
+
if (!core || core.remaining === undefined || core.limit === undefined || core.reset === undefined) {
|
|
64
|
+
throw new Error('Rate limit response missing expected fields (resources.core)');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.knownLimit = core.limit;
|
|
68
|
+
|
|
69
|
+
const percentUsed = core.limit > 0
|
|
70
|
+
? ((core.limit - core.remaining) / core.limit) * 100
|
|
71
|
+
: 0;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
remaining: core.remaining,
|
|
75
|
+
limit: core.limit,
|
|
76
|
+
resetAt: new Date(core.reset * 1000),
|
|
77
|
+
percentUsed,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Estimate how many API calls a sync operation will require.
|
|
83
|
+
*/
|
|
84
|
+
estimateApiCalls(specCount: number, userStoryCount: number): number {
|
|
85
|
+
if (specCount === 0 && userStoryCount === 0) {
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
return (specCount * CALLS_PER_SPEC_OVERHEAD) + (userStoryCount * CALLS_PER_USER_STORY);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check whether a sync operation can proceed given the current rate limit.
|
|
93
|
+
*/
|
|
94
|
+
async canProceed(estimatedCalls: number): Promise<CanProceedResult> {
|
|
95
|
+
const status = await this.checkRateLimit();
|
|
96
|
+
|
|
97
|
+
if (estimatedCalls > status.remaining) {
|
|
98
|
+
const waitMs = Math.max(0, status.resetAt.getTime() - Date.now());
|
|
99
|
+
return {
|
|
100
|
+
allowed: false,
|
|
101
|
+
waitMs,
|
|
102
|
+
reason: `Rate limit: need ${estimatedCalls} calls but only ${status.remaining}/${status.limit} remaining. Resets in ${Math.ceil(waitMs / 1000)}s`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (status.percentUsed > 90) {
|
|
107
|
+
const waitMs = Math.max(0, status.resetAt.getTime() - Date.now());
|
|
108
|
+
return {
|
|
109
|
+
allowed: false,
|
|
110
|
+
waitMs,
|
|
111
|
+
reason: `Rate limit at ${status.percentUsed.toFixed(0)}% — too high to proceed safely`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { allowed: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Record API calls made during a sync operation.
|
|
120
|
+
*/
|
|
121
|
+
recordUsage(calls: number): void {
|
|
122
|
+
if (calls < 0) {
|
|
123
|
+
throw new Error('Cannot record negative usage');
|
|
124
|
+
}
|
|
125
|
+
this.totalUsed += calls;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get the percentage of the known limit used by this limiter instance.
|
|
130
|
+
*/
|
|
131
|
+
getUsagePercent(): number {
|
|
132
|
+
if (this.knownLimit === 0) return 0;
|
|
133
|
+
const pct = (this.totalUsed / this.knownLimit) * 100;
|
|
134
|
+
return Math.min(pct, 100);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private getEnv(): NodeJS.ProcessEnv {
|
|
138
|
+
if (this.token) {
|
|
139
|
+
return { ...process.env, GH_TOKEN: this.token };
|
|
140
|
+
}
|
|
141
|
+
return process.env;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readFile, writeFile } from "fs/promises";
|
|
2
|
+
async function updateSpecFrontmatter(specPath, syncResult, options) {
|
|
3
|
+
const content = await readFile(specPath, "utf-8");
|
|
4
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
5
|
+
let frontmatter = {};
|
|
6
|
+
let body = content;
|
|
7
|
+
if (fmMatch) {
|
|
8
|
+
frontmatter = parseYamlSimple(fmMatch[1]);
|
|
9
|
+
body = content.slice(fmMatch[0].length);
|
|
10
|
+
}
|
|
11
|
+
const externalLinks = frontmatter.externalLinks ?? {};
|
|
12
|
+
const existingGithub = externalLinks.github ?? {};
|
|
13
|
+
const existingUserStories = existingGithub.userStories ?? {};
|
|
14
|
+
const userStories = { ...existingUserStories };
|
|
15
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
16
|
+
for (const item of syncResult.created) {
|
|
17
|
+
userStories[item.userStoryId] = {
|
|
18
|
+
issueNumber: item.issueNumber,
|
|
19
|
+
issueUrl: item.issueUrl,
|
|
20
|
+
issueNodeId: item.issueNodeId,
|
|
21
|
+
syncedAt: now
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
for (const item of syncResult.updated) {
|
|
25
|
+
const existing = userStories[item.userStoryId];
|
|
26
|
+
userStories[item.userStoryId] = {
|
|
27
|
+
issueNumber: item.issueNumber,
|
|
28
|
+
issueUrl: item.issueUrl,
|
|
29
|
+
issueNodeId: existing?.issueNodeId,
|
|
30
|
+
syncedAt: now
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const syncStatus = syncResult.errors.length > 0 ? "dirty" : "synced";
|
|
34
|
+
const metadata = {
|
|
35
|
+
syncStatus,
|
|
36
|
+
userStories
|
|
37
|
+
};
|
|
38
|
+
if (options?.projectV2Id) {
|
|
39
|
+
metadata.projectV2Id = options.projectV2Id;
|
|
40
|
+
} else if (existingGithub.projectV2Id) {
|
|
41
|
+
metadata.projectV2Id = existingGithub.projectV2Id;
|
|
42
|
+
}
|
|
43
|
+
if (options?.projectV2Number) {
|
|
44
|
+
metadata.projectV2Number = options.projectV2Number;
|
|
45
|
+
} else if (existingGithub.projectV2Number) {
|
|
46
|
+
metadata.projectV2Number = existingGithub.projectV2Number;
|
|
47
|
+
}
|
|
48
|
+
externalLinks.github = metadata;
|
|
49
|
+
frontmatter.externalLinks = externalLinks;
|
|
50
|
+
const newFrontmatter = stringifyYaml(frontmatter);
|
|
51
|
+
const newContent = `---
|
|
52
|
+
${newFrontmatter}
|
|
53
|
+
---${body}`;
|
|
54
|
+
await writeFile(specPath, newContent, "utf-8");
|
|
55
|
+
return metadata;
|
|
56
|
+
}
|
|
57
|
+
function parseYamlSimple(yaml) {
|
|
58
|
+
const result = {};
|
|
59
|
+
const lines = yaml.split("\n");
|
|
60
|
+
const stack = [
|
|
61
|
+
{ obj: result, indent: -1 }
|
|
62
|
+
];
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
65
|
+
const indent = line.search(/\S/);
|
|
66
|
+
const trimmed = line.trim();
|
|
67
|
+
const colonIdx = trimmed.indexOf(":");
|
|
68
|
+
if (colonIdx === -1) continue;
|
|
69
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
70
|
+
const rawValue = trimmed.slice(colonIdx + 1).trim();
|
|
71
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
72
|
+
stack.pop();
|
|
73
|
+
}
|
|
74
|
+
const parent = stack[stack.length - 1].obj;
|
|
75
|
+
if (rawValue === "" || rawValue === void 0) {
|
|
76
|
+
const child = {};
|
|
77
|
+
parent[key] = child;
|
|
78
|
+
stack.push({ obj: child, indent });
|
|
79
|
+
} else {
|
|
80
|
+
parent[key] = parseYamlValue(rawValue);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
function parseYamlValue(raw) {
|
|
86
|
+
if (raw === "null") return null;
|
|
87
|
+
if (raw === "true") return true;
|
|
88
|
+
if (raw === "false") return false;
|
|
89
|
+
if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
|
|
90
|
+
if (/^-?\d+\.\d+$/.test(raw)) return parseFloat(raw);
|
|
91
|
+
if (raw.startsWith('"') && raw.endsWith('"') || raw.startsWith("'") && raw.endsWith("'")) {
|
|
92
|
+
return raw.slice(1, -1);
|
|
93
|
+
}
|
|
94
|
+
return raw;
|
|
95
|
+
}
|
|
96
|
+
function stringifyYaml(obj, indent = 0) {
|
|
97
|
+
const prefix = " ".repeat(indent);
|
|
98
|
+
const parts = [];
|
|
99
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
100
|
+
if (value === null || value === void 0) {
|
|
101
|
+
parts.push(`${prefix}${key}: null`);
|
|
102
|
+
} else if (typeof value === "object" && !Array.isArray(value)) {
|
|
103
|
+
parts.push(`${prefix}${key}:`);
|
|
104
|
+
parts.push(stringifyYaml(value, indent + 1));
|
|
105
|
+
} else if (typeof value === "string") {
|
|
106
|
+
parts.push(`${prefix}${key}: "${value}"`);
|
|
107
|
+
} else if (typeof value === "boolean" || typeof value === "number") {
|
|
108
|
+
parts.push(`${prefix}${key}: ${value}`);
|
|
109
|
+
} else {
|
|
110
|
+
parts.push(`${prefix}${key}: ${JSON.stringify(value)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return parts.join("\n");
|
|
114
|
+
}
|
|
115
|
+
export {
|
|
116
|
+
updateSpecFrontmatter
|
|
117
|
+
};
|