forge-workflow 0.0.4 → 0.0.5

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.
Files changed (209) hide show
  1. package/.claude/commands/dev.md +340 -340
  2. package/.claude/commands/plan.md +521 -521
  3. package/.claude/commands/premerge.md +176 -176
  4. package/.claude/commands/research.md +42 -42
  5. package/.claude/commands/review.md +442 -442
  6. package/.claude/commands/rollback.md +721 -721
  7. package/.claude/commands/ship.md +164 -164
  8. package/.claude/commands/sonarcloud.md +152 -152
  9. package/.claude/commands/status.md +48 -48
  10. package/.claude/commands/validate.md +282 -282
  11. package/.claude/commands/verify.md +221 -221
  12. package/.claude/rules/greptile-review-process.md +285 -285
  13. package/.claude/rules/workflow.md +105 -105
  14. package/.claude/scripts/greptile-resolve.sh +526 -526
  15. package/.claude/scripts/load-env.sh +32 -32
  16. package/.cline/workflows/dev.md +337 -337
  17. package/.cline/workflows/plan.md +518 -518
  18. package/.cline/workflows/premerge.md +173 -173
  19. package/.cline/workflows/research.md +39 -39
  20. package/.cline/workflows/review.md +439 -439
  21. package/.cline/workflows/rollback.md +718 -718
  22. package/.cline/workflows/ship.md +161 -161
  23. package/.cline/workflows/sonarcloud.md +146 -146
  24. package/.cline/workflows/status.md +45 -45
  25. package/.cline/workflows/validate.md +279 -279
  26. package/.cline/workflows/verify.md +218 -218
  27. package/.codex/config.toml +11 -11
  28. package/.codex/skills/dev/SKILL.md +340 -340
  29. package/.codex/skills/plan/SKILL.md +521 -521
  30. package/.codex/skills/premerge/SKILL.md +176 -176
  31. package/.codex/skills/research/SKILL.md +42 -42
  32. package/.codex/skills/review/SKILL.md +442 -442
  33. package/.codex/skills/rollback/SKILL.md +721 -721
  34. package/.codex/skills/ship/SKILL.md +164 -164
  35. package/.codex/skills/sonarcloud/SKILL.md +149 -149
  36. package/.codex/skills/status/SKILL.md +48 -48
  37. package/.codex/skills/validate/SKILL.md +282 -282
  38. package/.codex/skills/verify/SKILL.md +221 -221
  39. package/.cursor/commands/dev.md +337 -337
  40. package/.cursor/commands/plan.md +518 -518
  41. package/.cursor/commands/premerge.md +173 -173
  42. package/.cursor/commands/research.md +39 -39
  43. package/.cursor/commands/review.md +439 -439
  44. package/.cursor/commands/rollback.md +718 -718
  45. package/.cursor/commands/ship.md +161 -161
  46. package/.cursor/commands/sonarcloud.md +146 -146
  47. package/.cursor/commands/status.md +45 -45
  48. package/.cursor/commands/validate.md +279 -279
  49. package/.cursor/commands/verify.md +218 -218
  50. package/.cursor/rules/permissions-guidance.mdc +37 -37
  51. package/.forge/hooks/check-tdd.js +240 -240
  52. package/.github/PLUGIN_TEMPLATE.json +32 -32
  53. package/.github/prompts/dev.prompt.md +342 -342
  54. package/.github/prompts/plan.prompt.md +523 -523
  55. package/.github/prompts/premerge.prompt.md +178 -178
  56. package/.github/prompts/research.prompt.md +44 -44
  57. package/.github/prompts/review.prompt.md +444 -444
  58. package/.github/prompts/rollback.prompt.md +723 -723
  59. package/.github/prompts/ship.prompt.md +166 -166
  60. package/.github/prompts/sonarcloud.prompt.md +151 -151
  61. package/.github/prompts/status.prompt.md +50 -50
  62. package/.github/prompts/validate.prompt.md +284 -284
  63. package/.github/prompts/verify.prompt.md +223 -223
  64. package/.github/workflows/beads-to-github.yml +56 -0
  65. package/.github/workflows/github-to-beads.yml +97 -0
  66. package/.kilocode/workflows/dev.md +341 -341
  67. package/.kilocode/workflows/plan.md +522 -522
  68. package/.kilocode/workflows/premerge.md +177 -177
  69. package/.kilocode/workflows/research.md +43 -43
  70. package/.kilocode/workflows/review.md +443 -443
  71. package/.kilocode/workflows/rollback.md +722 -722
  72. package/.kilocode/workflows/ship.md +165 -165
  73. package/.kilocode/workflows/sonarcloud.md +150 -150
  74. package/.kilocode/workflows/status.md +49 -49
  75. package/.kilocode/workflows/validate.md +283 -283
  76. package/.kilocode/workflows/verify.md +222 -222
  77. package/.mcp.json.example +12 -12
  78. package/.opencode/commands/dev.md +340 -340
  79. package/.opencode/commands/plan.md +521 -521
  80. package/.opencode/commands/premerge.md +176 -176
  81. package/.opencode/commands/research.md +42 -42
  82. package/.opencode/commands/review.md +442 -442
  83. package/.opencode/commands/rollback.md +721 -721
  84. package/.opencode/commands/ship.md +164 -164
  85. package/.opencode/commands/sonarcloud.md +149 -149
  86. package/.opencode/commands/status.md +48 -48
  87. package/.opencode/commands/validate.md +282 -282
  88. package/.opencode/commands/verify.md +221 -221
  89. package/.roo/commands/dev.md +341 -341
  90. package/.roo/commands/plan.md +522 -522
  91. package/.roo/commands/premerge.md +177 -177
  92. package/.roo/commands/research.md +43 -43
  93. package/.roo/commands/review.md +443 -443
  94. package/.roo/commands/rollback.md +722 -722
  95. package/.roo/commands/ship.md +165 -165
  96. package/.roo/commands/sonarcloud.md +150 -150
  97. package/.roo/commands/status.md +49 -49
  98. package/.roo/commands/validate.md +283 -283
  99. package/.roo/commands/verify.md +222 -222
  100. package/AGENTS.md +175 -175
  101. package/CLAUDE.md +100 -100
  102. package/README.md +429 -416
  103. package/bin/forge-cmd.js +313 -313
  104. package/bin/forge-preflight.js +309 -309
  105. package/bin/forge.js +4596 -4303
  106. package/docs/AGENT_INSTALL_PROMPT.md +342 -342
  107. package/docs/BEADS_GITHUB_SYNC.md +251 -251
  108. package/docs/ENHANCED_ONBOARDING.md +602 -602
  109. package/docs/EXAMPLES.md +482 -482
  110. package/docs/GREPTILE_SETUP.md +400 -400
  111. package/docs/MANUAL_REVIEW_GUIDE.md +106 -106
  112. package/docs/ROADMAP.md +359 -359
  113. package/docs/SETUP.md +663 -631
  114. package/docs/TOOLCHAIN.md +630 -630
  115. package/docs/VALIDATION.md +363 -363
  116. package/install.sh +40 -1056
  117. package/lefthook.yml +39 -39
  118. package/lib/agents/README.md +198 -198
  119. package/lib/agents/claude.plugin.json +28 -28
  120. package/lib/agents/cline.plugin.json +22 -22
  121. package/lib/agents/codex.plugin.json +19 -19
  122. package/lib/agents/copilot.plugin.json +24 -24
  123. package/lib/agents/cursor.plugin.json +25 -25
  124. package/lib/agents/kilocode.plugin.json +22 -22
  125. package/lib/agents/opencode.plugin.json +20 -20
  126. package/lib/agents/roo.plugin.json +23 -23
  127. package/lib/agents-config.js +2112 -2112
  128. package/lib/beads-health-check.js +143 -0
  129. package/lib/beads-setup.js +341 -0
  130. package/lib/beads-sync-scaffold.js +260 -0
  131. package/lib/commands/dev.js +513 -513
  132. package/lib/commands/plan.js +692 -692
  133. package/lib/commands/recommend.js +119 -119
  134. package/lib/commands/ship.js +377 -377
  135. package/lib/commands/status.js +378 -378
  136. package/lib/commands/validate.js +602 -602
  137. package/lib/context-merge.js +359 -359
  138. package/lib/dep-guard/analyzer.js +294 -294
  139. package/lib/dep-guard/behavior-detector.js +98 -98
  140. package/lib/dep-guard/contract-detector.js +162 -162
  141. package/lib/dep-guard/import-detector.js +498 -498
  142. package/lib/dep-guard/path-utils.js +13 -13
  143. package/lib/dep-guard/rubric.js +120 -120
  144. package/lib/dep-guard/task-parser.js +318 -318
  145. package/lib/detect-agent.js +191 -191
  146. package/lib/detect-worktree.js +47 -47
  147. package/lib/file-hash.js +26 -26
  148. package/lib/husky-migration.js +450 -0
  149. package/lib/lefthook-check.js +65 -0
  150. package/lib/pat-setup.js +207 -0
  151. package/lib/plugin-catalog.js +350 -350
  152. package/lib/plugin-manager.js +166 -166
  153. package/lib/plugin-recommender.js +141 -141
  154. package/lib/project-discovery.js +491 -491
  155. package/lib/setup-action-log.js +139 -139
  156. package/lib/setup-summary-renderer.js +106 -106
  157. package/lib/setup-utils.js +96 -0
  158. package/lib/setup.js +192 -192
  159. package/lib/smart-merge.js +64 -0
  160. package/lib/symlink-utils.js +81 -0
  161. package/lib/workflow-profiles.js +197 -197
  162. package/package.json +131 -128
  163. package/scripts/beads-context.sh +291 -0
  164. package/scripts/beads-context.test.js +563 -0
  165. package/scripts/behavioral-judge.sh +378 -0
  166. package/scripts/benchmark.js +85 -0
  167. package/scripts/branch-protection.js +183 -0
  168. package/scripts/check-agents.js +172 -0
  169. package/scripts/commitlint.js +42 -0
  170. package/scripts/conflict-detect.sh +323 -0
  171. package/scripts/dep-guard-analyze.js +71 -0
  172. package/scripts/dep-guard.sh +811 -0
  173. package/scripts/eval_win.py +249 -0
  174. package/scripts/file-index.sh +399 -0
  175. package/scripts/github-beads-sync/comment.mjs +64 -0
  176. package/scripts/github-beads-sync/config.mjs +148 -0
  177. package/scripts/github-beads-sync/github-api.mjs +131 -0
  178. package/scripts/github-beads-sync/index.mjs +332 -0
  179. package/scripts/github-beads-sync/label-mapper.mjs +54 -0
  180. package/scripts/github-beads-sync/mapping.mjs +78 -0
  181. package/scripts/github-beads-sync/reverse-sync-cli.mjs +31 -0
  182. package/scripts/github-beads-sync/reverse-sync.mjs +138 -0
  183. package/scripts/github-beads-sync/run-bd.mjs +159 -0
  184. package/scripts/github-beads-sync/sanitize.mjs +121 -0
  185. package/scripts/github-beads-sync.config.json +26 -0
  186. package/scripts/improve-command.js +375 -0
  187. package/scripts/lib/eval-runner.js +229 -0
  188. package/scripts/lib/eval-schema.js +135 -0
  189. package/scripts/lib/eval-storage.js +78 -0
  190. package/scripts/lib/grading.js +203 -0
  191. package/scripts/lib/transcript-parser.js +63 -0
  192. package/scripts/lint.js +47 -0
  193. package/scripts/migrate-to-bun-test.js +412 -0
  194. package/scripts/run-command-eval.js +236 -0
  195. package/scripts/smart-status.sh +782 -0
  196. package/scripts/sync-commands.js +571 -0
  197. package/scripts/sync-utils.sh +460 -0
  198. package/scripts/test-dashboard.js +123 -0
  199. package/scripts/test.js +44 -0
  200. package/scripts/validate.sh +94 -0
  201. package/skills/parallel-deep-research/SKILL.md +108 -108
  202. package/skills/parallel-deep-research/evals/README.md +27 -27
  203. package/skills/parallel-deep-research/evals/evals.json +62 -62
  204. package/skills/sonarcloud-analysis/SKILL.md +171 -171
  205. package/skills/sonarcloud-analysis/evals/README.md +27 -27
  206. package/skills/sonarcloud-analysis/evals/evals.json +50 -50
  207. package/skills/sonarcloud-analysis/references/api-reference.md +466 -466
  208. package/.cursor/hooks/state/continual-learning-index.json +0 -19
  209. package/.cursor/hooks/state/continual-learning.json +0 -8
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Bot comment parser/builder for GitHub-Beads sync.
3
+ *
4
+ * Produces and parses Vercel-inspired sync comments that use an
5
+ * edit-don't-create pattern with a stable HTML comment tag.
6
+ *
7
+ * @module comment
8
+ */
9
+
10
+ /** HTML comment prefix used to identify sync comments. */
11
+ export const SYNC_TAG_PREFIX = '<!-- beads-sync:';
12
+
13
+ /**
14
+ * Build a markdown sync comment for a GitHub issue.
15
+ *
16
+ * @param {string} beadsId - The Beads issue ID (e.g. "forge-abc").
17
+ * @param {number} issueNumber - The GitHub issue number.
18
+ * @param {object} [metadata] - Optional metadata fields.
19
+ * @param {string} [metadata.type] - Issue type (feature, bug, etc.).
20
+ * @param {string} [metadata.priority] - Priority label (P0-P4).
21
+ * @param {string} [metadata.externalRef] - External reference string.
22
+ * @returns {string} Markdown comment body.
23
+ */
24
+ export function buildComment(beadsId, issueNumber, metadata = {}) {
25
+ const timestamp = new Date().toISOString();
26
+
27
+ const detailLines = [];
28
+ if (metadata.type) detailLines.push(`- Type: ${metadata.type}`);
29
+ if (metadata.priority != null) detailLines.push(`- Priority: ${metadata.priority}`);
30
+ if (metadata.externalRef) detailLines.push(`- External ref: ${metadata.externalRef}`);
31
+ detailLines.push(`- Synced: ${timestamp}`);
32
+
33
+ return [
34
+ `${SYNC_TAG_PREFIX}${issueNumber} -->`,
35
+ `**Beads:** \`${beadsId}\``,
36
+ '<details>',
37
+ '<summary>Sync details</summary>',
38
+ '',
39
+ ...detailLines,
40
+ '</details>',
41
+ ].join('\n');
42
+ }
43
+
44
+ /**
45
+ * Parse a comment body and extract sync metadata.
46
+ *
47
+ * @param {string|null|undefined} commentBody - Raw comment markdown.
48
+ * @returns {{ beadsId: string, issueNumber: number } | null}
49
+ * Extracted IDs, or `null` if the comment is not a sync comment.
50
+ */
51
+ export function parseComment(commentBody) {
52
+ if (!commentBody) return null;
53
+
54
+ const tagMatch = commentBody.match(/<!--\s*beads-sync:(\d+)\s*-->/);
55
+ if (!tagMatch) return null;
56
+
57
+ const beadsMatch = commentBody.match(/\*\*Beads:\*\*\s*`([a-zA-Z]+-[a-zA-Z0-9]+)`/);
58
+ if (!beadsMatch) return null;
59
+
60
+ return {
61
+ beadsId: beadsMatch[1],
62
+ issueNumber: Number(tagMatch[1]),
63
+ };
64
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Config loader for GitHub-Beads issue sync.
3
+ * @module scripts/github-beads-sync/config
4
+ */
5
+
6
+ import { readFileSync } from 'node:fs';
7
+
8
+ /**
9
+ * Default configuration for GitHub-Beads sync.
10
+ * @type {Readonly<SyncConfig>}
11
+ */
12
+ export const DEFAULT_CONFIG = Object.freeze({
13
+ labelToType: Object.freeze({
14
+ bug: 'bug',
15
+ enhancement: 'feature',
16
+ documentation: 'task',
17
+ question: 'task',
18
+ }),
19
+ labelToPriority: Object.freeze({
20
+ P0: 0, critical: 0,
21
+ P1: 1, high: 1,
22
+ P2: 2, medium: 2,
23
+ P3: 3, low: 3,
24
+ P4: 4, backlog: 4,
25
+ }),
26
+ defaultType: 'task',
27
+ defaultPriority: 2,
28
+ mapAssignee: true,
29
+ publicRepoGate: 'none',
30
+ gateLabelName: 'beads-track',
31
+ gateAssociations: Object.freeze(['MEMBER', 'COLLABORATOR', 'OWNER']),
32
+ });
33
+
34
+ /**
35
+ * Deep-merge source into target (one level of nesting).
36
+ * Arrays and scalars from source overwrite target.
37
+ * Plain objects are merged key-by-key.
38
+ * @param {object} target
39
+ * @param {object} source
40
+ * @returns {object} Merged result (new object)
41
+ */
42
+ function deepMerge(target, source) {
43
+ const result = { ...target };
44
+ for (const key of Object.keys(source)) {
45
+ const srcVal = source[key];
46
+ const tgtVal = target[key];
47
+ if (
48
+ srcVal !== null &&
49
+ typeof srcVal === 'object' &&
50
+ !Array.isArray(srcVal) &&
51
+ tgtVal !== null &&
52
+ typeof tgtVal === 'object' &&
53
+ !Array.isArray(tgtVal)
54
+ ) {
55
+ result[key] = { ...tgtVal, ...srcVal };
56
+ } else {
57
+ result[key] = srcVal;
58
+ }
59
+ }
60
+ return result;
61
+ }
62
+
63
+ /**
64
+ * Clone DEFAULT_CONFIG into a mutable plain object.
65
+ * @returns {object}
66
+ */
67
+ function cloneDefaults() {
68
+ return {
69
+ ...DEFAULT_CONFIG,
70
+ labelToType: { ...DEFAULT_CONFIG.labelToType },
71
+ labelToPriority: { ...DEFAULT_CONFIG.labelToPriority },
72
+ gateAssociations: [...DEFAULT_CONFIG.gateAssociations],
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Load and validate sync configuration.
78
+ * - No arguments: returns a copy of DEFAULT_CONFIG.
79
+ * - With path: reads JSON file and deep-merges with defaults (user overrides win).
80
+ * - Missing file: returns defaults (no throw).
81
+ * - Invalid JSON: throws with helpful message.
82
+ *
83
+ * @param {string} [configPath] - Path to a JSON config file.
84
+ * @returns {object} Merged configuration object.
85
+ */
86
+ export function loadConfig(configPath) {
87
+ if (!configPath) {
88
+ return cloneDefaults();
89
+ }
90
+
91
+ let raw;
92
+ try {
93
+ raw = readFileSync(configPath, 'utf-8');
94
+ } catch {
95
+ // File not found or unreadable — return defaults
96
+ return cloneDefaults();
97
+ }
98
+
99
+ let userConfig;
100
+ try {
101
+ userConfig = JSON.parse(raw);
102
+ } catch (parseErr) {
103
+ throw new Error(
104
+ `Invalid JSON in config file: ${configPath}\n` +
105
+ `Parse error: ${parseErr.message}`,
106
+ { cause: parseErr }
107
+ );
108
+ }
109
+
110
+ const merged = deepMerge(cloneDefaults(), userConfig);
111
+ return validateConfig(merged);
112
+ }
113
+
114
+ /**
115
+ * Validate and normalize config values.
116
+ * Replaces invalid types with defaults rather than throwing.
117
+ * @param {object} cfg
118
+ * @returns {object} Validated config
119
+ */
120
+ function validateConfig(cfg) {
121
+ const defaults = cloneDefaults();
122
+ if (!cfg.labelToType || typeof cfg.labelToType !== 'object' || Array.isArray(cfg.labelToType)) {
123
+ cfg.labelToType = defaults.labelToType;
124
+ }
125
+ if (!cfg.labelToPriority || typeof cfg.labelToPriority !== 'object' || Array.isArray(cfg.labelToPriority)) {
126
+ cfg.labelToPriority = defaults.labelToPriority;
127
+ }
128
+ if (typeof cfg.defaultPriority !== 'number') {
129
+ cfg.defaultPriority = defaults.defaultPriority;
130
+ }
131
+ if (typeof cfg.mapAssignee !== 'boolean') {
132
+ cfg.mapAssignee = defaults.mapAssignee;
133
+ }
134
+ if (!Array.isArray(cfg.gateAssociations)) {
135
+ cfg.gateAssociations = defaults.gateAssociations;
136
+ }
137
+ const VALID_GATE_VALUES = ['none', 'author_association', 'label'];
138
+ if (!VALID_GATE_VALUES.includes(cfg.publicRepoGate)) {
139
+ cfg.publicRepoGate = defaults.publicRepoGate;
140
+ }
141
+ if (typeof cfg.defaultType !== 'string' || !cfg.defaultType) {
142
+ cfg.defaultType = defaults.defaultType;
143
+ }
144
+ if (typeof cfg.gateLabelName !== 'string' || !cfg.gateLabelName) {
145
+ cfg.gateLabelName = defaults.gateLabelName;
146
+ }
147
+ return cfg;
148
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * GitHub API caller module using `gh api` CLI.
3
+ * Builds argument arrays for execFileSync('gh', args) — no shell invocation.
4
+ * @module github-api
5
+ */
6
+
7
+ import { execFileSync } from 'node:child_process';
8
+
9
+ const SYNC_MARKER = '<!-- beads-sync:';
10
+
11
+ /**
12
+ * Builds args for listing all comments on a GitHub issue.
13
+ * @param {string} owner - Repository owner
14
+ * @param {string} repo - Repository name
15
+ * @param {number|string} issueNumber - Issue number
16
+ * @returns {string[]} Args array for execFileSync('gh', args)
17
+ */
18
+ export function buildFindCommentsArgs(owner, repo, issueNumber) {
19
+ return [
20
+ 'api',
21
+ `repos/${owner}/${repo}/issues/${issueNumber}/comments`,
22
+ ];
23
+ }
24
+
25
+ /**
26
+ * Finds the beads-sync comment in a list of comment objects.
27
+ * @param {Array<{id: number, body: string}>} comments - Array of GitHub comment objects
28
+ * @returns {{id: number, body: string}|null} The sync comment or null
29
+ */
30
+ export function parseFindSyncComment(comments) {
31
+ const match = comments.find((c) => c.body && c.body.includes(SYNC_MARKER));
32
+ if (!match) return null;
33
+ return { id: match.id, body: match.body };
34
+ }
35
+
36
+ /**
37
+ * Builds args for creating a comment on a GitHub issue.
38
+ * @param {string} owner - Repository owner
39
+ * @param {string} repo - Repository name
40
+ * @param {number|string} issueNumber - Issue number
41
+ * @param {string} body - Comment body
42
+ * @returns {string[]} Args array for execFileSync('gh', args)
43
+ */
44
+ export function buildCreateCommentArgs(owner, repo, issueNumber, body) {
45
+ return [
46
+ 'api',
47
+ `repos/${owner}/${repo}/issues/${issueNumber}/comments`,
48
+ '-f',
49
+ `body=${body}`,
50
+ ];
51
+ }
52
+
53
+ /**
54
+ * Builds args for editing an existing comment.
55
+ * @param {string} owner - Repository owner
56
+ * @param {string} repo - Repository name
57
+ * @param {number|string} commentId - Comment ID
58
+ * @param {string} body - Updated comment body
59
+ * @returns {string[]} Args array for execFileSync('gh', args)
60
+ */
61
+ export function buildEditCommentArgs(owner, repo, commentId, body) {
62
+ return [
63
+ 'api',
64
+ `repos/${owner}/${repo}/issues/comments/${commentId}`,
65
+ '-X',
66
+ 'PATCH',
67
+ '-f',
68
+ `body=${body}`,
69
+ ];
70
+ }
71
+
72
+ /**
73
+ * Builds args for closing a GitHub issue.
74
+ * @param {string} owner - Repository owner
75
+ * @param {string} repo - Repository name
76
+ * @param {number|string} issueNumber - Issue number
77
+ * @returns {string[]} Args array for execFileSync('gh', args)
78
+ */
79
+ export function buildCloseIssueArgs(owner, repo, issueNumber) {
80
+ return [
81
+ 'api',
82
+ `repos/${owner}/${repo}/issues/${issueNumber}`,
83
+ '-X',
84
+ 'PATCH',
85
+ '-f',
86
+ 'state=closed',
87
+ ];
88
+ }
89
+
90
+ /**
91
+ * Finds the existing beads-sync comment on an issue, or returns null.
92
+ * @param {string} owner - Repository owner
93
+ * @param {string} repo - Repository name
94
+ * @param {number|string} issueNumber - Issue number
95
+ * @returns {{id: number, body: string}|null}
96
+ */
97
+ export function findSyncComment(owner, repo, issueNumber) {
98
+ const args = buildFindCommentsArgs(owner, repo, issueNumber);
99
+ const raw = execFileSync('gh', args, { encoding: 'utf-8' });
100
+ const comments = JSON.parse(raw);
101
+ return parseFindSyncComment(comments);
102
+ }
103
+
104
+ /**
105
+ * Creates or edits the beads-sync comment on an issue.
106
+ * If a sync comment already exists, edits it; otherwise creates a new one.
107
+ * @param {string} owner - Repository owner
108
+ * @param {string} repo - Repository name
109
+ * @param {number|string} issueNumber - Issue number
110
+ * @param {string} body - Comment body (should contain the beads-sync marker)
111
+ * @returns {void}
112
+ */
113
+ export function createOrEditComment(owner, repo, issueNumber, body) {
114
+ const existing = findSyncComment(owner, repo, issueNumber);
115
+ const args = existing
116
+ ? buildEditCommentArgs(owner, repo, existing.id, body)
117
+ : buildCreateCommentArgs(owner, repo, issueNumber, body);
118
+ execFileSync('gh', args, { encoding: 'utf-8' });
119
+ }
120
+
121
+ /**
122
+ * Closes a GitHub issue.
123
+ * @param {string} owner - Repository owner
124
+ * @param {string} repo - Repository name
125
+ * @param {number|string} issueNumber - Issue number
126
+ * @returns {void}
127
+ */
128
+ export function closeIssue(owner, repo, issueNumber) {
129
+ const args = buildCloseIssueArgs(owner, repo, issueNumber);
130
+ execFileSync('gh', args, { encoding: 'utf-8' });
131
+ }
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Main entry point for GitHub-Beads issue sync.
3
+ * Orchestrates all Wave 1 modules to handle GitHub webhook events.
4
+ *
5
+ * @module scripts/github-beads-sync/index
6
+ */
7
+
8
+ import { readFileSync, appendFileSync } from 'node:fs';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { loadConfig } from './config.mjs';
11
+ import { sanitizeTitle } from './sanitize.mjs';
12
+ import { mapLabels } from './label-mapper.mjs';
13
+ import { bdCreate as realBdCreate, bdClose as realBdClose, bdShow as realBdShow } from './run-bd.mjs';
14
+ import { getBeadsId as realGetBeadsId, setBeadsId as realSetBeadsId } from './mapping.mjs';
15
+ import { buildComment, parseComment } from './comment.mjs';
16
+ import {
17
+ findSyncComment as realFindSyncComment,
18
+ createOrEditComment as realCreateOrEditComment,
19
+ } from './github-api.mjs';
20
+
21
+ /**
22
+ * Check if a sender login matches known bot patterns.
23
+ * @param {string} login - GitHub sender login
24
+ * @returns {boolean}
25
+ */
26
+ function isBot(login) {
27
+ if (!login) return false;
28
+ return login.includes('[bot]') || login === 'github-actions';
29
+ }
30
+
31
+ /**
32
+ * Check if an issue has the skip-beads-sync label.
33
+ * @param {Array<{name: string}|string>} labels
34
+ * @returns {boolean}
35
+ */
36
+ function hasSkipLabel(labels) {
37
+ if (!labels) return false;
38
+ return labels.some((l) => {
39
+ const name = typeof l === 'string' ? l : l.name;
40
+ return name === 'skip-beads-sync';
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Handle a GitHub issue "opened" event.
46
+ *
47
+ * @param {object} event - Parsed GitHub webhook payload
48
+ * @param {object} options
49
+ * @param {string} [options.configPath] - Path to config JSON
50
+ * @param {string} options.mappingPath - Path to mapping JSON
51
+ * @param {string} options.owner - Repository owner
52
+ * @param {string} options.repo - Repository name
53
+ * @param {object} [options.bd] - Dependency injection for bd functions
54
+ * @param {object} [options.github] - Dependency injection for github-api functions
55
+ * @param {object} [options.mapping] - Dependency injection for mapping functions
56
+ * @param {object} [options.configOverride] - Merge into loaded config (for testing)
57
+ * @returns {object} Result object
58
+ */
59
+ export function handleOpened(event, options = {}) {
60
+ const {
61
+ configPath,
62
+ mappingPath,
63
+ owner,
64
+ repo,
65
+ bd = {},
66
+ github = {},
67
+ mapping = {},
68
+ configOverride,
69
+ } = options;
70
+
71
+ const bdCreate = bd.bdCreate ?? realBdCreate;
72
+ const findSyncComment = github.findSyncComment ?? realFindSyncComment;
73
+ const createOrEditComment = github.createOrEditComment ?? realCreateOrEditComment;
74
+ const getBeadsId = mapping.getBeadsId ?? realGetBeadsId;
75
+ const setBeadsId = mapping.setBeadsId ?? realSetBeadsId;
76
+
77
+ // 1. Load config
78
+ let config = loadConfig(configPath);
79
+ if (configOverride) {
80
+ config = { ...config, ...configOverride };
81
+ }
82
+
83
+ // 2. Extract event data
84
+ const issue = event.issue;
85
+ const issueNumber = issue.number;
86
+ const rawTitle = issue.title;
87
+ const labels = issue.labels || [];
88
+ const assignee = config.mapAssignee ? issue.assignee?.login : undefined;
89
+ const htmlUrl = issue.html_url;
90
+ const body = issue.body ?? '';
91
+ const authorAssociation = issue.author_association;
92
+
93
+ // 3. Guard: bot actor
94
+ if (isBot(event.sender?.login)) {
95
+ return { skipped: true, reason: 'bot actor' };
96
+ }
97
+
98
+ // 4. Guard: skip label
99
+ if (hasSkipLabel(labels)) {
100
+ return { skipped: true, reason: 'skip label' };
101
+ }
102
+
103
+ // 5. Guard: no-beads body
104
+ if (body.includes('no-beads')) {
105
+ return { skipped: true, reason: 'no-beads in body' };
106
+ }
107
+
108
+ // 6. Guard: public repo gate
109
+ if (config.publicRepoGate === 'author_association') {
110
+ const allowed = config.gateAssociations || [];
111
+ if (!allowed.includes(authorAssociation)) {
112
+ return { skipped: true, reason: 'author not authorized' };
113
+ }
114
+ } else if (config.publicRepoGate === 'label') {
115
+ const gateLabelName = config.gateLabelName || 'beads-track';
116
+ const hasGateLabel = labels.some((l) => {
117
+ const name = typeof l === 'string' ? l : l.name;
118
+ return name === gateLabelName;
119
+ });
120
+ if (!hasGateLabel) {
121
+ return { skipped: true, reason: `missing required label: ${gateLabelName}` };
122
+ }
123
+ }
124
+
125
+ // 7. Idempotency — check mapping file first (fast, survives comment deletion)
126
+ const existingBeadsId = getBeadsId(mappingPath, issueNumber);
127
+ if (existingBeadsId) {
128
+ // Mapping exists — ensure the GitHub comment is present (repair if deleted)
129
+ const existingComment = findSyncComment(owner, repo, issueNumber);
130
+ if (!existingComment) {
131
+ const externalRef = `gh-${issueNumber}`;
132
+ const commentBody = buildComment(existingBeadsId, issueNumber, { externalRef });
133
+ createOrEditComment(owner, repo, issueNumber, commentBody);
134
+ }
135
+ return {
136
+ skipped: true,
137
+ reason: 'already synced (mapping file)',
138
+ beadsId: existingBeadsId,
139
+ };
140
+ }
141
+
142
+ // 7b. Fallback idempotency — check for existing sync comment
143
+ const existingComment = findSyncComment(owner, repo, issueNumber);
144
+ if (existingComment) {
145
+ const parsed = parseComment(existingComment.body);
146
+ // Repair mapping file from comment
147
+ if (parsed?.beadsId) {
148
+ setBeadsId(mappingPath, issueNumber, parsed.beadsId);
149
+ }
150
+ return {
151
+ skipped: true,
152
+ reason: 'already synced (comment)',
153
+ beadsId: parsed?.beadsId ?? null,
154
+ };
155
+ }
156
+
157
+ // 8. Sanitize title (labels mapped from raw names — sanitization only needed for CLI args)
158
+ const { sanitized: sanitizedTitle, warnings: titleWarnings } = sanitizeTitle(rawTitle);
159
+ if (titleWarnings.length) console.warn('sanitize:', titleWarnings);
160
+
161
+ // 9. Map labels using raw names (case-insensitive matching in mapLabels handles normalization)
162
+ const rawLabelNames = labels.map((l) => (typeof l === 'string' ? l : l.name));
163
+ const { type, priority } = mapLabels(rawLabelNames, config);
164
+
165
+ // 10. Create beads issue
166
+ const externalRef = `gh-${issueNumber}`;
167
+ const beadsId = bdCreate({
168
+ title: sanitizedTitle,
169
+ type,
170
+ priority,
171
+ assignee,
172
+ description: htmlUrl,
173
+ externalRef,
174
+ });
175
+
176
+ if (!beadsId) {
177
+ return { success: false, reason: 'bd create failed — no beads ID returned', issueNumber };
178
+ }
179
+
180
+ // 11. Update mapping
181
+ setBeadsId(mappingPath, issueNumber, beadsId);
182
+
183
+ // 12. Build and post comment
184
+ const commentBody = buildComment(beadsId, issueNumber, { type, priority, externalRef });
185
+ createOrEditComment(owner, repo, issueNumber, commentBody);
186
+
187
+ // 13. Return success
188
+ return { success: true, beadsId, issueNumber };
189
+ }
190
+
191
+ /**
192
+ * Handle a GitHub issue "closed" event.
193
+ *
194
+ * @param {object} event - Parsed GitHub webhook payload
195
+ * @param {object} options
196
+ * @param {string} options.mappingPath - Path to mapping JSON
197
+ * @param {string} options.owner - Repository owner
198
+ * @param {string} options.repo - Repository name
199
+ * @param {object} [options.bd] - Dependency injection for bd functions
200
+ * @param {object} [options.github] - Dependency injection for github-api functions
201
+ * @param {object} [options.mapping] - Dependency injection for mapping functions
202
+ * @returns {object} Result object
203
+ */
204
+ export function handleClosed(event, options = {}) {
205
+ const {
206
+ mappingPath,
207
+ owner,
208
+ repo,
209
+ bd = {},
210
+ github = {},
211
+ mapping = {},
212
+ } = options;
213
+
214
+ const bdClose = bd.bdClose ?? realBdClose;
215
+ const bdShow = bd.bdShow ?? realBdShow;
216
+ const getBeadsId = mapping.getBeadsId ?? realGetBeadsId;
217
+ const findSyncComment = github.findSyncComment ?? realFindSyncComment;
218
+
219
+ // 1. Extract issue number, labels, and state reason
220
+ const issueNumber = event.issue.number;
221
+ const labels = event.issue.labels || [];
222
+ const stateReason = event.issue.state_reason;
223
+
224
+ // 2. Guard: bot actor
225
+ if (isBot(event.sender?.login)) {
226
+ return { skipped: true, reason: 'bot actor' };
227
+ }
228
+
229
+ // 3. Guard: skip label
230
+ if (hasSkipLabel(labels)) {
231
+ return { skipped: true, reason: 'skip label' };
232
+ }
233
+
234
+ // 4. Guard: skip "not planned" closures (only close on "completed")
235
+ if (stateReason && stateReason !== 'completed') {
236
+ return { skipped: true, reason: `closed as ${stateReason}` };
237
+ }
238
+
239
+ // 5. Read mapping
240
+ let beadsId = getBeadsId(mappingPath, issueNumber);
241
+
242
+ // 6. Fallback: find via sync comment
243
+ if (!beadsId) {
244
+ const comment = findSyncComment(owner, repo, issueNumber);
245
+ if (comment) {
246
+ const parsed = parseComment(comment.body);
247
+ // Verify the comment's tagged issue number matches current issue (prevents cross-issue close)
248
+ if (parsed && parsed.issueNumber === issueNumber) {
249
+ beadsId = parsed.beadsId;
250
+ }
251
+ }
252
+ }
253
+
254
+ // 7. No beads link found
255
+ if (!beadsId) {
256
+ return { skipped: true, reason: 'no beads link found' };
257
+ }
258
+
259
+ // 8. Check if already closed
260
+ const status = bdShow(beadsId);
261
+ if (status === null) {
262
+ return { skipped: true, reason: 'could not determine beads status' };
263
+ }
264
+ if (status === 'closed') {
265
+ return { skipped: true, reason: 'already closed' };
266
+ }
267
+
268
+ // 9. Close beads issue
269
+ bdClose(beadsId, `Closed via GitHub issue #${issueNumber}`);
270
+
271
+ // 10. Return success
272
+ return { success: true, beadsId, issueNumber };
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // CLI entry point
277
+ // ---------------------------------------------------------------------------
278
+ const __filename = fileURLToPath(import.meta.url);
279
+ if (process.argv[1] === __filename) {
280
+ const action = process.argv[2];
281
+ const eventPath = process.env.GITHUB_EVENT_PATH || process.argv[3];
282
+
283
+ if (!action || !eventPath) {
284
+ console.error('Usage: node index.mjs <opened|closed> [event-path]');
285
+ console.error(' GITHUB_EVENT_PATH env var is used if event-path arg is omitted.');
286
+ process.exit(1);
287
+ }
288
+
289
+ const event = JSON.parse(readFileSync(eventPath, 'utf-8'));
290
+ const [owner, repo] = (process.env.GITHUB_REPOSITORY || 'unknown/unknown').split('/');
291
+
292
+ const options = {
293
+ configPath: process.env.BEADS_SYNC_CONFIG || undefined,
294
+ mappingPath: process.env.BEADS_SYNC_MAPPING || '.github/beads-mapping.json',
295
+ owner,
296
+ repo,
297
+ };
298
+
299
+ // Validate action explicitly — only allow known actions
300
+ const VALID_ACTIONS = ['opened', 'closed'];
301
+ if (!VALID_ACTIONS.includes(action)) {
302
+ console.error(`Unknown action: "${action}". Expected one of: ${VALID_ACTIONS.join(', ')}`);
303
+ process.exit(1);
304
+ }
305
+
306
+ const handler = action === 'opened' ? handleOpened : handleClosed;
307
+
308
+ Promise.resolve(handler(event, options))
309
+ .then((result) => {
310
+ console.log(JSON.stringify(result, null, 2));
311
+
312
+ // Exit non-zero on logical failure (e.g., bdCreate returned null)
313
+ if (result.success === false) {
314
+ console.error(`Sync failed: ${result.reason || 'unknown'}`);
315
+ process.exit(1);
316
+ }
317
+
318
+ // Write to GITHUB_OUTPUT if available
319
+ const outputPath = process.env.GITHUB_OUTPUT;
320
+ if (outputPath) {
321
+ for (const [key, value] of Object.entries(result)) {
322
+ if (value != null) {
323
+ appendFileSync(outputPath, `${key}=${value}\n`);
324
+ }
325
+ }
326
+ }
327
+ })
328
+ .catch((err) => {
329
+ console.error('Fatal:', err.message);
330
+ process.exit(1);
331
+ });
332
+ }