specweave 1.0.351 → 1.0.352

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 (211) hide show
  1. package/bin/specweave.js +9 -0
  2. package/dist/plugins/specweave-ado/lib/ado-client-v2.d.ts +5 -0
  3. package/dist/plugins/specweave-ado/lib/ado-client-v2.d.ts.map +1 -1
  4. package/dist/plugins/specweave-ado/lib/ado-client-v2.js +61 -23
  5. package/dist/plugins/specweave-ado/lib/ado-client-v2.js.map +1 -1
  6. package/dist/plugins/specweave-ado/lib/ado-duplicate-detector.d.ts.map +1 -1
  7. package/dist/plugins/specweave-ado/lib/ado-duplicate-detector.js +3 -2
  8. package/dist/plugins/specweave-ado/lib/ado-duplicate-detector.js.map +1 -1
  9. package/dist/plugins/specweave-ado/lib/ado-profile-resolver.d.ts.map +1 -1
  10. package/dist/plugins/specweave-ado/lib/ado-profile-resolver.js +2 -1
  11. package/dist/plugins/specweave-ado/lib/ado-profile-resolver.js.map +1 -1
  12. package/dist/plugins/specweave-ado/lib/ado-spec-sync.d.ts +1 -1
  13. package/dist/plugins/specweave-ado/lib/ado-spec-sync.d.ts.map +1 -1
  14. package/dist/plugins/specweave-ado/lib/ado-spec-sync.js +25 -9
  15. package/dist/plugins/specweave-ado/lib/ado-spec-sync.js.map +1 -1
  16. package/dist/plugins/specweave-ado/lib/conflict-resolver.d.ts.map +1 -1
  17. package/dist/plugins/specweave-ado/lib/conflict-resolver.js +17 -1
  18. package/dist/plugins/specweave-ado/lib/conflict-resolver.js.map +1 -1
  19. package/dist/plugins/specweave-ado/lib/per-us-sync.d.ts +3 -0
  20. package/dist/plugins/specweave-ado/lib/per-us-sync.d.ts.map +1 -1
  21. package/dist/plugins/specweave-ado/lib/per-us-sync.js +14 -1
  22. package/dist/plugins/specweave-ado/lib/per-us-sync.js.map +1 -1
  23. package/dist/plugins/specweave-github/lib/github-client-v2.d.ts.map +1 -1
  24. package/dist/plugins/specweave-github/lib/github-client-v2.js +10 -7
  25. package/dist/plugins/specweave-github/lib/github-client-v2.js.map +1 -1
  26. package/dist/plugins/specweave-github/lib/github-client.d.ts +1 -1
  27. package/dist/plugins/specweave-github/lib/github-client.d.ts.map +1 -1
  28. package/dist/plugins/specweave-github/lib/github-client.js +7 -5
  29. package/dist/plugins/specweave-github/lib/github-client.js.map +1 -1
  30. package/dist/plugins/specweave-github/lib/github-cross-repo-sync.js +13 -3
  31. package/dist/plugins/specweave-github/lib/github-cross-repo-sync.js.map +1 -1
  32. package/dist/plugins/specweave-github/lib/github-feature-sync-cli.d.ts +24 -1
  33. package/dist/plugins/specweave-github/lib/github-feature-sync-cli.d.ts.map +1 -1
  34. package/dist/plugins/specweave-github/lib/github-feature-sync-cli.js +36 -20
  35. package/dist/plugins/specweave-github/lib/github-feature-sync-cli.js.map +1 -1
  36. package/dist/plugins/specweave-github/lib/github-graphql-client.d.ts +1 -0
  37. package/dist/plugins/specweave-github/lib/github-graphql-client.d.ts.map +1 -1
  38. package/dist/plugins/specweave-github/lib/github-graphql-client.js +32 -22
  39. package/dist/plugins/specweave-github/lib/github-graphql-client.js.map +1 -1
  40. package/dist/plugins/specweave-github/lib/github-spec-frontmatter-updater.js +144 -8
  41. package/dist/plugins/specweave-github/lib/github-spec-frontmatter-updater.js.map +1 -1
  42. package/dist/plugins/specweave-github/lib/github-spec-sync.d.ts +8 -1
  43. package/dist/plugins/specweave-github/lib/github-spec-sync.d.ts.map +1 -1
  44. package/dist/plugins/specweave-github/lib/github-spec-sync.js +94 -24
  45. package/dist/plugins/specweave-github/lib/github-spec-sync.js.map +1 -1
  46. package/dist/plugins/specweave-github/lib/github-sync-orchestrator.d.ts +1 -0
  47. package/dist/plugins/specweave-github/lib/github-sync-orchestrator.d.ts.map +1 -1
  48. package/dist/plugins/specweave-github/lib/github-sync-orchestrator.js +2 -1
  49. package/dist/plugins/specweave-github/lib/github-sync-orchestrator.js.map +1 -1
  50. package/dist/plugins/specweave-github/lib/github-us-auto-closer.d.ts.map +1 -1
  51. package/dist/plugins/specweave-github/lib/github-us-auto-closer.js +25 -0
  52. package/dist/plugins/specweave-github/lib/github-us-auto-closer.js.map +1 -1
  53. package/dist/plugins/specweave-github/lib/per-us-sync.d.ts +3 -0
  54. package/dist/plugins/specweave-github/lib/per-us-sync.d.ts.map +1 -1
  55. package/dist/plugins/specweave-github/lib/per-us-sync.js +29 -9
  56. package/dist/plugins/specweave-github/lib/per-us-sync.js.map +1 -1
  57. package/dist/plugins/specweave-jira/lib/content-format-adapter.d.ts +59 -0
  58. package/dist/plugins/specweave-jira/lib/content-format-adapter.d.ts.map +1 -0
  59. package/dist/plugins/specweave-jira/lib/content-format-adapter.js +159 -0
  60. package/dist/plugins/specweave-jira/lib/content-format-adapter.js.map +1 -0
  61. package/dist/plugins/specweave-jira/lib/jira-deployment-detector.d.ts +45 -0
  62. package/dist/plugins/specweave-jira/lib/jira-deployment-detector.d.ts.map +1 -0
  63. package/dist/plugins/specweave-jira/lib/jira-deployment-detector.js +92 -0
  64. package/dist/plugins/specweave-jira/lib/jira-deployment-detector.js.map +1 -0
  65. package/dist/plugins/specweave-jira/lib/jira-duplicate-detector.d.ts.map +1 -1
  66. package/dist/plugins/specweave-jira/lib/jira-duplicate-detector.js +13 -28
  67. package/dist/plugins/specweave-jira/lib/jira-duplicate-detector.js.map +1 -1
  68. package/dist/plugins/specweave-jira/lib/jira-epic-sync.d.ts +2 -1
  69. package/dist/plugins/specweave-jira/lib/jira-epic-sync.d.ts.map +1 -1
  70. package/dist/plugins/specweave-jira/lib/jira-epic-sync.js +19 -7
  71. package/dist/plugins/specweave-jira/lib/jira-epic-sync.js.map +1 -1
  72. package/dist/plugins/specweave-jira/lib/jira-field-discovery.d.ts +47 -0
  73. package/dist/plugins/specweave-jira/lib/jira-field-discovery.d.ts.map +1 -0
  74. package/dist/plugins/specweave-jira/lib/jira-field-discovery.js +110 -0
  75. package/dist/plugins/specweave-jira/lib/jira-field-discovery.js.map +1 -0
  76. package/dist/plugins/specweave-jira/lib/jira-paginated-search.d.ts +26 -0
  77. package/dist/plugins/specweave-jira/lib/jira-paginated-search.d.ts.map +1 -0
  78. package/dist/plugins/specweave-jira/lib/jira-paginated-search.js +77 -0
  79. package/dist/plugins/specweave-jira/lib/jira-paginated-search.js.map +1 -0
  80. package/dist/plugins/specweave-jira/lib/jira-spec-commit-sync.d.ts.map +1 -1
  81. package/dist/plugins/specweave-jira/lib/jira-spec-commit-sync.js +5 -3
  82. package/dist/plugins/specweave-jira/lib/jira-spec-commit-sync.js.map +1 -1
  83. package/dist/plugins/specweave-jira/lib/jira-spec-sync.d.ts +17 -2
  84. package/dist/plugins/specweave-jira/lib/jira-spec-sync.d.ts.map +1 -1
  85. package/dist/plugins/specweave-jira/lib/jira-spec-sync.js +103 -33
  86. package/dist/plugins/specweave-jira/lib/jira-spec-sync.js.map +1 -1
  87. package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts +4 -0
  88. package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts.map +1 -1
  89. package/dist/plugins/specweave-jira/lib/jira-status-sync.js +19 -6
  90. package/dist/plugins/specweave-jira/lib/jira-status-sync.js.map +1 -1
  91. package/dist/plugins/specweave-jira/lib/metadata-paths.d.ts +29 -0
  92. package/dist/plugins/specweave-jira/lib/metadata-paths.d.ts.map +1 -0
  93. package/dist/plugins/specweave-jira/lib/metadata-paths.js +73 -0
  94. package/dist/plugins/specweave-jira/lib/metadata-paths.js.map +1 -0
  95. package/dist/plugins/specweave-jira/lib/reorganization-detector.d.ts +15 -2
  96. package/dist/plugins/specweave-jira/lib/reorganization-detector.d.ts.map +1 -1
  97. package/dist/plugins/specweave-jira/lib/reorganization-detector.js +121 -33
  98. package/dist/plugins/specweave-jira/lib/reorganization-detector.js.map +1 -1
  99. package/dist/src/cli/commands/sync-progress.d.ts +6 -0
  100. package/dist/src/cli/commands/sync-progress.d.ts.map +1 -1
  101. package/dist/src/cli/commands/sync-progress.js +37 -0
  102. package/dist/src/cli/commands/sync-progress.js.map +1 -1
  103. package/dist/src/cli/commands/sync-task.d.ts +16 -0
  104. package/dist/src/cli/commands/sync-task.d.ts.map +1 -0
  105. package/dist/src/cli/commands/sync-task.js +42 -0
  106. package/dist/src/cli/commands/sync-task.js.map +1 -0
  107. package/dist/src/core/hooks/LifecycleHookDispatcher.d.ts +9 -1
  108. package/dist/src/core/hooks/LifecycleHookDispatcher.d.ts.map +1 -1
  109. package/dist/src/core/hooks/LifecycleHookDispatcher.js +26 -8
  110. package/dist/src/core/hooks/LifecycleHookDispatcher.js.map +1 -1
  111. package/dist/src/core/increment/metadata-manager.d.ts.map +1 -1
  112. package/dist/src/core/increment/metadata-manager.js +26 -0
  113. package/dist/src/core/increment/metadata-manager.js.map +1 -1
  114. package/dist/src/core/increment/status-commands.d.ts.map +1 -1
  115. package/dist/src/core/increment/status-commands.js +33 -11
  116. package/dist/src/core/increment/status-commands.js.map +1 -1
  117. package/dist/src/locales/de/cli.json +245 -77
  118. package/dist/src/locales/es/cli.json +238 -3
  119. package/dist/src/locales/fr/cli.json +254 -86
  120. package/dist/src/locales/ja/cli.json +246 -78
  121. package/dist/src/locales/ko/cli.json +246 -78
  122. package/dist/src/locales/pt/cli.json +245 -77
  123. package/dist/src/locales/zh/cli.json +251 -83
  124. package/dist/src/sync/github-reconciler.d.ts +30 -0
  125. package/dist/src/sync/github-reconciler.d.ts.map +1 -1
  126. package/dist/src/sync/github-reconciler.js +235 -1
  127. package/dist/src/sync/github-reconciler.js.map +1 -1
  128. package/package.json +1 -1
  129. package/plugins/specweave/hooks/v2/guards/task-ac-sync-guard.sh +31 -0
  130. package/plugins/specweave/lib/vendor/core/increment/metadata-manager.js +26 -0
  131. package/plugins/specweave/lib/vendor/core/increment/metadata-manager.js.map +1 -1
  132. package/plugins/specweave/lib/vendor/sync/github-reconciler.d.ts +30 -0
  133. package/plugins/specweave/lib/vendor/sync/github-reconciler.js +235 -1
  134. package/plugins/specweave/lib/vendor/sync/github-reconciler.js.map +1 -1
  135. package/plugins/specweave/skills/team-lead/agents/security.md +0 -1
  136. package/plugins/specweave-ado/hooks/post-task-completion.sh +2 -2
  137. package/plugins/specweave-ado/lib/ado-client-v2.js +51 -21
  138. package/plugins/specweave-ado/lib/ado-client-v2.ts +62 -23
  139. package/plugins/specweave-ado/lib/ado-duplicate-detector.js +4 -4
  140. package/plugins/specweave-ado/lib/ado-duplicate-detector.ts +4 -3
  141. package/plugins/specweave-ado/lib/ado-hierarchical-sync.js +54 -12
  142. package/plugins/specweave-ado/lib/ado-hierarchical-sync.ts +88 -18
  143. package/plugins/specweave-ado/lib/ado-profile-resolver.js +1 -1
  144. package/plugins/specweave-ado/lib/ado-profile-resolver.ts +3 -1
  145. package/plugins/specweave-ado/lib/ado-spec-sync.js +22 -9
  146. package/plugins/specweave-ado/lib/ado-spec-sync.ts +27 -9
  147. package/plugins/specweave-ado/lib/conflict-resolver.js +17 -1
  148. package/plugins/specweave-ado/lib/conflict-resolver.ts +17 -1
  149. package/plugins/specweave-ado/lib/enhanced-ado-sync.js +11 -1
  150. package/plugins/specweave-ado/lib/per-us-sync.js +8 -1
  151. package/plugins/specweave-ado/lib/per-us-sync.ts +17 -2
  152. package/plugins/specweave-github/hooks/github-auto-create-handler.sh +28 -2
  153. package/plugins/specweave-github/hooks/post-task-completion.sh +6 -3
  154. package/plugins/specweave-github/lib/enhanced-github-sync.js +35 -6
  155. package/plugins/specweave-github/lib/github-board-resolver.js +4 -4
  156. package/plugins/specweave-github/lib/github-board-resolver.ts +4 -4
  157. package/plugins/specweave-github/lib/github-client-v2.js +6 -6
  158. package/plugins/specweave-github/lib/github-client-v2.ts +11 -7
  159. package/plugins/specweave-github/lib/github-client.js +5 -4
  160. package/plugins/specweave-github/lib/github-client.ts +7 -5
  161. package/plugins/specweave-github/lib/github-cross-repo-sync.js +17 -3
  162. package/plugins/specweave-github/lib/github-cross-repo-sync.ts +16 -3
  163. package/plugins/specweave-github/lib/github-feature-sync-cli.js +20 -11
  164. package/plugins/specweave-github/lib/github-feature-sync-cli.ts +42 -20
  165. package/plugins/specweave-github/lib/github-graphql-client.js +29 -20
  166. package/plugins/specweave-github/lib/github-graphql-client.ts +34 -22
  167. package/plugins/specweave-github/lib/github-hierarchical-sync.js +2 -2
  168. package/plugins/specweave-github/lib/github-hierarchical-sync.ts +2 -2
  169. package/plugins/specweave-github/lib/github-multi-project-sync.js +23 -7
  170. package/plugins/specweave-github/lib/github-multi-project-sync.ts +26 -8
  171. package/plugins/specweave-github/lib/github-spec-frontmatter-updater.js +110 -5
  172. package/plugins/specweave-github/lib/github-spec-frontmatter-updater.ts +135 -9
  173. package/plugins/specweave-github/lib/github-spec-sync.js +85 -24
  174. package/plugins/specweave-github/lib/github-spec-sync.ts +100 -26
  175. package/plugins/specweave-github/lib/github-sync-orchestrator.js +2 -1
  176. package/plugins/specweave-github/lib/github-sync-orchestrator.ts +3 -1
  177. package/plugins/specweave-github/lib/github-us-auto-closer.js +25 -0
  178. package/plugins/specweave-github/lib/github-us-auto-closer.ts +43 -0
  179. package/plugins/specweave-github/lib/per-us-sync.js +26 -11
  180. package/plugins/specweave-github/lib/per-us-sync.ts +29 -11
  181. package/plugins/specweave-jira/hooks/post-task-completion.sh +2 -1
  182. package/plugins/specweave-jira/lib/content-format-adapter.js +116 -0
  183. package/plugins/specweave-jira/lib/content-format-adapter.ts +189 -0
  184. package/plugins/specweave-jira/lib/enhanced-jira-sync.js +21 -5
  185. package/plugins/specweave-jira/lib/jira-deployment-detector.js +63 -0
  186. package/plugins/specweave-jira/lib/jira-deployment-detector.ts +113 -0
  187. package/plugins/specweave-jira/lib/jira-duplicate-detector.js +12 -29
  188. package/plugins/specweave-jira/lib/jira-duplicate-detector.ts +13 -27
  189. package/plugins/specweave-jira/lib/jira-epic-sync.js +15 -5
  190. package/plugins/specweave-jira/lib/jira-epic-sync.ts +22 -7
  191. package/plugins/specweave-jira/lib/jira-field-discovery.js +76 -0
  192. package/plugins/specweave-jira/lib/jira-field-discovery.ts +139 -0
  193. package/plugins/specweave-jira/lib/jira-hierarchical-sync.js +10 -0
  194. package/plugins/specweave-jira/lib/jira-hierarchical-sync.ts +11 -0
  195. package/plugins/specweave-jira/lib/jira-multi-project-sync.js +19 -9
  196. package/plugins/specweave-jira/lib/jira-multi-project-sync.ts +25 -14
  197. package/plugins/specweave-jira/lib/jira-paginated-search.js +55 -0
  198. package/plugins/specweave-jira/lib/jira-paginated-search.ts +108 -0
  199. package/plugins/specweave-jira/lib/jira-spec-commit-sync.js +5 -3
  200. package/plugins/specweave-jira/lib/jira-spec-commit-sync.ts +5 -3
  201. package/plugins/specweave-jira/lib/jira-spec-sync.js +102 -31
  202. package/plugins/specweave-jira/lib/jira-spec-sync.ts +123 -45
  203. package/plugins/specweave-jira/lib/jira-status-sync.js +18 -5
  204. package/plugins/specweave-jira/lib/jira-status-sync.ts +21 -6
  205. package/plugins/specweave-jira/lib/metadata-paths.js +38 -0
  206. package/plugins/specweave-jira/lib/metadata-paths.ts +73 -0
  207. package/plugins/specweave-jira/lib/reorganization-detector.js +101 -23
  208. package/plugins/specweave-jira/lib/reorganization-detector.ts +125 -35
  209. package/plugins/specweave-jira/scripts/refresh-cache.js +1 -1
  210. package/plugins/specweave-jira/scripts/refresh-cache.ts +2 -2
  211. package/plugins/specweave-jira/skills/jira-resource-validator/SKILL.md +3 -5
@@ -27,9 +27,10 @@ async function syncSpecWithEnhancedContent(options) {
27
27
  console.log(`\u{1F517} Found ${mapping.increments.length} related increments`);
28
28
  console.log(`\u{1F4CB} Mapped ${Object.keys(mapping.userStoryMappings).length} user stories to tasks`);
29
29
  }
30
- const taskMapping = buildTaskMapping(mapping.increments, owner, repo);
30
+ const defaultBranch = (owner && repo) ? await getDefaultBranch(owner, repo) : null;
31
+ const taskMapping = buildTaskMapping(mapping.increments, owner, repo, defaultBranch);
31
32
  const architectureDocs = await findArchitectureDocs(rootDir, specId);
32
- const sourceLinks = buildSourceLinks(mapping.increments[0]?.id, owner, repo);
33
+ const sourceLinks = buildSourceLinks(mapping.increments[0]?.id, owner, repo, defaultBranch);
33
34
  const enhancedSpec = {
34
35
  ...baseSpec,
35
36
  summary: baseSpec.description,
@@ -153,7 +154,33 @@ async function findSpecWeaveRoot(specPath) {
153
154
  }
154
155
  }
155
156
  }
156
- function buildTaskMapping(increments, owner, repo) {
157
+ // Cache for default branch per repo per session
158
+ const defaultBranchCache = new Map();
159
+
160
+ async function getDefaultBranch(owner, repo) {
161
+ const cacheKey = `${owner}/${repo}`;
162
+ if (defaultBranchCache.has(cacheKey)) {
163
+ return defaultBranchCache.get(cacheKey);
164
+ }
165
+ try {
166
+ const { execFileSync } = await import("child_process");
167
+ const result = execFileSync("gh", [
168
+ "repo", "view", `${owner}/${repo}`,
169
+ "--json", "defaultBranchRef",
170
+ "--jq", ".defaultBranchRef.name"
171
+ ], { encoding: "utf-8", timeout: 10000 }).trim();
172
+ if (result) {
173
+ defaultBranchCache.set(cacheKey, result);
174
+ return result;
175
+ }
176
+ } catch {
177
+ // On failure, return null — callers omit branch segment
178
+ }
179
+ defaultBranchCache.set(cacheKey, null);
180
+ return null;
181
+ }
182
+
183
+ function buildTaskMapping(increments, owner, repo, defaultBranch) {
157
184
  if (increments.length === 0) return void 0;
158
185
  const firstIncrement = increments[0];
159
186
  const tasks = firstIncrement.tasks.map((task) => ({
@@ -162,10 +189,11 @@ function buildTaskMapping(increments, owner, repo) {
162
189
  userStories: task.userStories,
163
190
  githubIssue: task.githubIssue
164
191
  }));
192
+ const branchSegment = defaultBranch ? `/blob/${defaultBranch}` : "";
165
193
  return {
166
194
  incrementId: firstIncrement.id,
167
195
  tasks,
168
- tasksUrl: `https://github.com/${owner}/${repo}/blob/develop/.specweave/increments/${firstIncrement.id}/tasks.md`
196
+ tasksUrl: `https://github.com/${owner}/${repo}${branchSegment}/.specweave/increments/${firstIncrement.id}/tasks.md`
169
197
  };
170
198
  }
171
199
  async function findArchitectureDocs(rootDir, specId) {
@@ -198,9 +226,10 @@ async function findArchitectureDocs(rootDir, specId) {
198
226
  }
199
227
  return docs;
200
228
  }
201
- function buildSourceLinks(incrementId, owner, repo) {
229
+ function buildSourceLinks(incrementId, owner, repo, defaultBranch) {
202
230
  if (!incrementId) return void 0;
203
- const baseUrl = `https://github.com/${owner}/${repo}/blob/develop/.specweave`;
231
+ const branchSegment = defaultBranch ? `/blob/${defaultBranch}` : "";
232
+ const baseUrl = `https://github.com/${owner}/${repo}${branchSegment}/.specweave`;
204
233
  return {
205
234
  spec: `${baseUrl}/docs/internal/specs/default/spec-${incrementId.replace(/^\d+-/, "")}.md`,
206
235
  plan: `${baseUrl}/increments/${incrementId}/plan.md`,
@@ -15,9 +15,9 @@ async function fetchBoardsForRepo(owner, repo) {
15
15
  "-H",
16
16
  "Accept: application/vnd.github+json"
17
17
  ], { env: getGhEnv() });
18
- if (result.status !== 0) {
18
+ if (result.exitCode !== 0) {
19
19
  console.error(`\u274C Failed to fetch boards for ${owner}/${repo}:`, result.stderr);
20
- throw new Error(`GitHub API error: ${result.status} ${result.stderr}`);
20
+ throw new Error(`GitHub API error (exit code ${result.exitCode}): ${result.stderr}`);
21
21
  }
22
22
  const boards = result.stdout.trim().split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
23
23
  console.log(`\u2705 Found ${boards.length} board(s) for repo ${owner}/${repo}`);
@@ -38,9 +38,9 @@ async function fetchBoardsForOrg(org) {
38
38
  "-H",
39
39
  "Accept: application/vnd.github+json"
40
40
  ], { env: getGhEnv() });
41
- if (result.status !== 0) {
41
+ if (result.exitCode !== 0) {
42
42
  console.error(`\u274C Failed to fetch boards for org ${org}:`, result.stderr);
43
- throw new Error(`GitHub API error: ${result.status} ${result.stderr}`);
43
+ throw new Error(`GitHub API error (exit code ${result.exitCode}): ${result.stderr}`);
44
44
  }
45
45
  const boards = result.stdout.trim().split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
46
46
  console.log(`\u2705 Found ${boards.length} board(s) for org ${org}`);
@@ -54,9 +54,9 @@ export async function fetchBoardsForRepo(
54
54
  'Accept: application/vnd.github+json',
55
55
  ], { env: getGhEnv() });
56
56
 
57
- if (result.status !== 0) {
57
+ if (result.exitCode !== 0) {
58
58
  console.error(`❌ Failed to fetch boards for ${owner}/${repo}:`, result.stderr);
59
- throw new Error(`GitHub API error: ${result.status} ${result.stderr}`);
59
+ throw new Error(`GitHub API error (exit code ${result.exitCode}): ${result.stderr}`);
60
60
  }
61
61
 
62
62
  // Parse JSONL (one JSON object per line)
@@ -98,9 +98,9 @@ export async function fetchBoardsForOrg(
98
98
  'Accept: application/vnd.github+json',
99
99
  ], { env: getGhEnv() });
100
100
 
101
- if (result.status !== 0) {
101
+ if (result.exitCode !== 0) {
102
102
  console.error(`❌ Failed to fetch boards for org ${org}:`, result.stderr);
103
- throw new Error(`GitHub API error: ${result.status} ${result.stderr}`);
103
+ throw new Error(`GitHub API error (exit code ${result.exitCode}): ${result.stderr}`);
104
104
  }
105
105
 
106
106
  // Parse JSONL
@@ -103,13 +103,14 @@ class GitHubClientV2 {
103
103
  /**
104
104
  * Create or get existing milestone
105
105
  */
106
- async createOrGetMilestone(title, description, daysFromNow = 2) {
106
+ async createOrGetMilestone(title, description, daysFromNow) {
107
+ const dueDays = daysFromNow ?? 2;
107
108
  const existing = await this.getMilestoneByTitle(title);
108
109
  if (existing) {
109
110
  return existing;
110
111
  }
111
112
  const dueDate = /* @__PURE__ */ new Date();
112
- dueDate.setDate(dueDate.getDate() + daysFromNow);
113
+ dueDate.setDate(dueDate.getDate() + dueDays);
113
114
  const dueDateISO = dueDate.toISOString();
114
115
  const args = [
115
116
  "api",
@@ -136,7 +137,7 @@ class GitHubClientV2 {
136
137
  async getMilestoneByTitle(title) {
137
138
  const result = await execFileNoThrow("gh", [
138
139
  "api",
139
- `repos/${this.fullRepo}/milestones`,
140
+ `repos/${this.fullRepo}/milestones?per_page=100&state=all`,
140
141
  "--jq",
141
142
  `.[] | select(.title=="${title}") | {number: .number, title: .title, description: .description, state: .state}`
142
143
  ], { env: this.getGhEnv() });
@@ -477,10 +478,9 @@ ${body}`;
477
478
  async getLastComment(issueNumber) {
478
479
  const result = await execFileNoThrow("gh", [
479
480
  "api",
480
- `repos/${this.fullRepo}/issues/${issueNumber}/comments`,
481
+ `repos/${this.fullRepo}/issues/${issueNumber}/comments?sort=created&direction=desc&per_page=1`,
481
482
  "--jq",
482
- ".[-1] | {body: .body, author: .user.login}"
483
- // Get last comment only
483
+ ".[0] | {body: .body, author: .user.login}"
484
484
  ], { env: this.getGhEnv() });
485
485
  if (result.exitCode !== 0) {
486
486
  return null;
@@ -148,17 +148,20 @@ export class GitHubClientV2 {
148
148
  async createOrGetMilestone(
149
149
  title: string,
150
150
  description?: string,
151
- daysFromNow: number = 2
151
+ daysFromNow?: number
152
152
  ): Promise<GitHubMilestone> {
153
+ // Use configured value or default to 2 days
154
+ const dueDays = daysFromNow ?? 2;
155
+
153
156
  // Check if milestone already exists
154
157
  const existing = await this.getMilestoneByTitle(title);
155
158
  if (existing) {
156
159
  return existing;
157
160
  }
158
161
 
159
- // Calculate due date
162
+ // Calculate due date from creation timestamp
160
163
  const dueDate = new Date();
161
- dueDate.setDate(dueDate.getDate() + daysFromNow);
164
+ dueDate.setDate(dueDate.getDate() + dueDays);
162
165
  const dueDateISO = dueDate.toISOString();
163
166
 
164
167
  // Build API request
@@ -194,7 +197,7 @@ export class GitHubClientV2 {
194
197
  ): Promise<GitHubMilestone | null> {
195
198
  const result = await execFileNoThrow('gh', [
196
199
  'api',
197
- `repos/${this.fullRepo}/milestones`,
200
+ `repos/${this.fullRepo}/milestones?per_page=100&state=all`,
198
201
  '--jq',
199
202
  `.[] | select(.title=="${title}") | {number: .number, title: .title, description: .description, state: .state}`,
200
203
  ], { env: this.getGhEnv() });
@@ -607,12 +610,13 @@ export class GitHubClientV2 {
607
610
  * Returns the most recent comment body, or null if no comments exist
608
611
  */
609
612
  async getLastComment(issueNumber: number): Promise<{body: string; author: string} | null> {
610
- // Get all comments (sorted by creation date, newest last)
613
+ // Query the last comment directly using per_page=1 + page from last page
614
+ // sort=created&direction=desc gives newest first, per_page=1 returns just one
611
615
  const result = await execFileNoThrow('gh', [
612
616
  'api',
613
- `repos/${this.fullRepo}/issues/${issueNumber}/comments`,
617
+ `repos/${this.fullRepo}/issues/${issueNumber}/comments?sort=created&direction=desc&per_page=1`,
614
618
  '--jq',
615
- '.[-1] | {body: .body, author: .user.login}', // Get last comment only
619
+ '.[0] | {body: .body, author: .user.login}',
616
620
  ], { env: this.getGhEnv() });
617
621
 
618
622
  if (result.exitCode !== 0) {
@@ -40,15 +40,16 @@ class GitHubClient {
40
40
  *
41
41
  * @param title Milestone title
42
42
  * @param description Milestone description
43
- * @param daysFromNow Days until milestone due (default: 2 days - SpecWeave AI velocity)
43
+ * @param daysFromNow Days until milestone due (configurable via github.milestoneDueDays, default: 2)
44
44
  */
45
- async createOrGetMilestone(title, description, daysFromNow = 2) {
45
+ async createOrGetMilestone(title, description, daysFromNow) {
46
+ const dueDays = daysFromNow ?? 2;
46
47
  const existing = await this.getMilestoneByTitle(title);
47
48
  if (existing) {
48
49
  return existing;
49
50
  }
50
51
  const dueDate = /* @__PURE__ */ new Date();
51
- dueDate.setDate(dueDate.getDate() + daysFromNow);
52
+ dueDate.setDate(dueDate.getDate() + dueDays);
52
53
  const dueDateISO = dueDate.toISOString();
53
54
  const cmd = `gh api repos/${this.repo}/milestones -f title="${title}" ${description ? `-f description="${description}"` : ""} -f due_on="${dueDateISO}" --jq '{number: .number, title: .title, description: .description, state: .state, due_on: .due_on}'`;
54
55
  try {
@@ -63,7 +64,7 @@ class GitHubClient {
63
64
  */
64
65
  async getMilestoneByTitle(title) {
65
66
  try {
66
- const cmd = `gh api repos/${this.repo}/milestones --jq '.[] | select(.title=="${title}") | {number: .number, title: .title, description: .description, state: .state}'`;
67
+ const cmd = `gh api "repos/${this.repo}/milestones?per_page=100&state=all" --jq '.[] | select(.title=="${title}") | {number: .number, title: .title, description: .description, state: .state}'`;
67
68
  const output = execSync(cmd, { encoding: "utf-8" }).trim();
68
69
  return output ? JSON.parse(output) : null;
69
70
  } catch {
@@ -54,22 +54,24 @@ export class GitHubClient {
54
54
  *
55
55
  * @param title Milestone title
56
56
  * @param description Milestone description
57
- * @param daysFromNow Days until milestone due (default: 2 days - SpecWeave AI velocity)
57
+ * @param daysFromNow Days until milestone due (configurable via github.milestoneDueDays, default: 2)
58
58
  */
59
59
  async createOrGetMilestone(
60
60
  title: string,
61
61
  description?: string,
62
- daysFromNow: number = 2
62
+ daysFromNow?: number
63
63
  ): Promise<GitHubMilestone> {
64
+ // Use configured value or default to 2 days
65
+ const dueDays = daysFromNow ?? 2;
64
66
  // Check if milestone already exists
65
67
  const existing = await this.getMilestoneByTitle(title);
66
68
  if (existing) {
67
69
  return existing;
68
70
  }
69
71
 
70
- // Calculate due date (SpecWeave default: 1-2 days with AI assistance)
72
+ // Calculate due date from creation timestamp
71
73
  const dueDate = new Date();
72
- dueDate.setDate(dueDate.getDate() + daysFromNow);
74
+ dueDate.setDate(dueDate.getDate() + dueDays);
73
75
  const dueDateISO = dueDate.toISOString();
74
76
 
75
77
  // Create new milestone with due date
@@ -88,7 +90,7 @@ export class GitHubClient {
88
90
  */
89
91
  private async getMilestoneByTitle(title: string): Promise<GitHubMilestone | null> {
90
92
  try {
91
- const cmd = `gh api repos/${this.repo}/milestones --jq '.[] | select(.title=="${title}") | {number: .number, title: .title, description: .description, state: .state}'`;
93
+ const cmd = `gh api "repos/${this.repo}/milestones?per_page=100&state=all" --jq '.[] | select(.title=="${title}") | {number: .number, title: .title, description: .description, state: .state}'`;
92
94
  const output = execSync(cmd, { encoding: 'utf-8' }).trim();
93
95
  return output ? JSON.parse(output) : null;
94
96
  } catch {
@@ -134,6 +134,22 @@ async function updateIssue(issueNumber, title, body, repo, env) {
134
134
  }
135
135
  }
136
136
  async function appendToIssueBody(issueNumber, repo, section, env) {
137
+ const viewRes = await execFileNoThrow("gh", [
138
+ "issue",
139
+ "view",
140
+ String(issueNumber),
141
+ "--repo",
142
+ repo,
143
+ "--json",
144
+ "body",
145
+ "-q",
146
+ ".body"
147
+ ], { env });
148
+ const existingBody = viewRes.success ? (viewRes.stdout || "").trim() : "";
149
+ const combinedBody = existingBody ? `${existingBody}
150
+
151
+ ---
152
+ ${section}` : section;
137
153
  const res = await execFileNoThrow("gh", [
138
154
  "issue",
139
155
  "edit",
@@ -141,9 +157,7 @@ async function appendToIssueBody(issueNumber, repo, section, env) {
141
157
  "--repo",
142
158
  repo,
143
159
  "--body",
144
- section,
145
- "--json",
146
- "number,url"
160
+ combinedBody
147
161
  ], { env });
148
162
  if (!res.success) {
149
163
  throw new Error(`Cross-ref update failed: ${res.stderr}`);
@@ -226,12 +226,25 @@ async function appendToIssueBody(
226
226
  section: string,
227
227
  env: NodeJS.ProcessEnv,
228
228
  ): Promise<void> {
229
- // Use gh issue edit with --add-body
229
+ // Read existing body first to avoid overwriting it
230
+ const viewRes = await execFileNoThrow('gh', [
231
+ 'issue', 'view', String(issueNumber),
232
+ '--repo', repo,
233
+ '--json', 'body',
234
+ '-q', '.body',
235
+ ], { env });
236
+
237
+ const existingBody = viewRes.success ? (viewRes.stdout || '').trim() : '';
238
+
239
+ // Concatenate: if existing body is empty, use section directly (no leading separator)
240
+ const combinedBody = existingBody
241
+ ? `${existingBody}\n\n---\n${section}`
242
+ : section;
243
+
230
244
  const res = await execFileNoThrow('gh', [
231
245
  'issue', 'edit', String(issueNumber),
232
246
  '--repo', repo,
233
- '--body', section,
234
- '--json', 'number,url',
247
+ '--body', combinedBody,
235
248
  ], { env });
236
249
 
237
250
  if (!res.success) {
@@ -3,19 +3,20 @@ import { existsSync, readFileSync } from "fs";
3
3
  import * as path from "path";
4
4
  import { GitHubFeatureSync } from "./github-feature-sync.js";
5
5
  import { GitHubClientV2 } from "./github-client-v2.js";
6
- async function loadGitHubConfig() {
7
- const projectRoot = process.cwd();
6
+ async function resolveGitHubConfig(options) {
7
+ const projectRoot = options?.projectRoot || process.cwd();
8
8
  const configPath = path.join(projectRoot, ".specweave/config.json");
9
- let owner = process.env.GITHUB_OWNER || "";
10
- let repo = process.env.GITHUB_REPO || "";
11
- const token = process.env.GITHUB_TOKEN || "";
12
- if (existsSync(configPath)) {
9
+ let owner = options?.cliOwner || process.env.GITHUB_OWNER || "";
10
+ let repo = options?.cliRepo || process.env.GITHUB_REPO || "";
11
+ const token = options?.cliToken || process.env.GITHUB_TOKEN || "";
12
+ if ((!owner || !repo) && existsSync(configPath)) {
13
13
  try {
14
14
  const config = JSON.parse(readFileSync(configPath, "utf-8"));
15
- if (config.sync?.github?.owner && config.sync?.github?.repo) {
15
+ if (!owner && !repo && config.sync?.github?.owner && config.sync?.github?.repo) {
16
16
  owner = config.sync.github.owner;
17
17
  repo = config.sync.github.repo;
18
- } else if (config.multiProject?.enabled && config.multiProject?.activeProject) {
18
+ }
19
+ if (!owner && !repo && config.multiProject?.enabled && config.multiProject?.activeProject) {
19
20
  const activeProject = config.multiProject.activeProject;
20
21
  const projectConfig = config.multiProject.projects?.[activeProject];
21
22
  if (projectConfig?.externalTools?.github?.repository) {
@@ -25,13 +26,15 @@ async function loadGitHubConfig() {
25
26
  repo = parts[1];
26
27
  }
27
28
  }
28
- } else if (config.sync?.defaultProfile && config.sync?.profiles) {
29
+ }
30
+ if (!owner && !repo && config.sync?.defaultProfile && config.sync?.profiles) {
29
31
  const profile = config.sync.profiles[config.sync.defaultProfile];
30
32
  if (profile?.config?.owner && profile?.config?.repo) {
31
33
  owner = profile.config.owner;
32
34
  repo = profile.config.repo;
33
35
  }
34
- } else if (config.sync?.profiles) {
36
+ }
37
+ if (!owner && !repo && config.sync?.profiles) {
35
38
  const profileNames = Object.keys(config.sync.profiles);
36
39
  for (const name of profileNames) {
37
40
  const profile = config.sync.profiles[name];
@@ -74,6 +77,9 @@ async function loadGitHubConfig() {
74
77
  }
75
78
  return { owner, repo, token };
76
79
  }
80
+ async function loadGitHubConfig() {
81
+ return resolveGitHubConfig();
82
+ }
77
83
  async function main() {
78
84
  const args = process.argv.slice(2);
79
85
  if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
@@ -83,7 +89,7 @@ async function main() {
83
89
  console.log(" feature-id Feature ID (e.g., FS-062)");
84
90
  console.log("");
85
91
  console.log("Environment:");
86
- console.log(" GITHUB_TOKEN Required - GitHub personal access token");
92
+ console.log(" GITHUB_TOKEN Required - GitHub PAT with repo scope");
87
93
  console.log("");
88
94
  console.log("Example:");
89
95
  console.log(" GITHUB_TOKEN=ghp_xxx node github-feature-sync-cli.js FS-062");
@@ -149,3 +155,6 @@ main().catch((error) => {
149
155
  console.error("Fatal error:", error);
150
156
  process.exit(1);
151
157
  });
158
+ export {
159
+ resolveGitHubConfig
160
+ };
@@ -29,26 +29,46 @@ interface GitHubConfig {
29
29
  token: string;
30
30
  }
31
31
 
32
- async function loadGitHubConfig(): Promise<GitHubConfig | null> {
33
- const projectRoot = process.cwd();
32
+ /**
33
+ * Resolve GitHub config (owner/repo) with a single canonical precedence order.
34
+ *
35
+ * Resolution order (documented for both CLI and hooks):
36
+ * 1. CLI flags / env vars: GITHUB_OWNER, GITHUB_REPO
37
+ * 2. .specweave/config.json — sync.github.owner/repo
38
+ * 3. .specweave/config.json — multiProject.projects[active].externalTools.github
39
+ * 4. .specweave/config.json — sync.profiles[defaultProfile].config
40
+ * 5. .specweave/config.json — first GitHub profile (fallback)
41
+ * 6. Git remote detection (git remote get-url origin)
42
+ *
43
+ * This is the SINGLE source of truth for owner/repo config.
44
+ * Shell hooks should read the same config.json paths in the same order.
45
+ */
46
+ export async function resolveGitHubConfig(options?: {
47
+ cliOwner?: string;
48
+ cliRepo?: string;
49
+ cliToken?: string;
50
+ projectRoot?: string;
51
+ }): Promise<{ owner: string; repo: string; token: string } | null> {
52
+ const projectRoot = options?.projectRoot || process.cwd();
34
53
  const configPath = path.join(projectRoot, '.specweave/config.json');
35
54
 
36
- let owner = process.env.GITHUB_OWNER || '';
37
- let repo = process.env.GITHUB_REPO || '';
38
- const token = process.env.GITHUB_TOKEN || '';
55
+ // Step 1: CLI flags / env vars (highest precedence)
56
+ let owner = options?.cliOwner || process.env.GITHUB_OWNER || '';
57
+ let repo = options?.cliRepo || process.env.GITHUB_REPO || '';
58
+ const token = options?.cliToken || process.env.GITHUB_TOKEN || '';
39
59
 
40
- // Try to load from config.json
41
- if (existsSync(configPath)) {
60
+ // Step 2-5: config.json methods (only if not already resolved)
61
+ if ((!owner || !repo) && existsSync(configPath)) {
42
62
  try {
43
63
  const config = JSON.parse(readFileSync(configPath, 'utf-8'));
44
64
 
45
- // Method 1: sync.github
46
- if (config.sync?.github?.owner && config.sync?.github?.repo) {
65
+ // Method 2: sync.github
66
+ if (!owner && !repo && config.sync?.github?.owner && config.sync?.github?.repo) {
47
67
  owner = config.sync.github.owner;
48
68
  repo = config.sync.github.repo;
49
69
  }
50
- // Method 2: multiProject.projects[activeProject].externalTools.github
51
- else if (config.multiProject?.enabled && config.multiProject?.activeProject) {
70
+ // Method 3: multiProject.projects[activeProject].externalTools.github
71
+ if (!owner && !repo && config.multiProject?.enabled && config.multiProject?.activeProject) {
52
72
  const activeProject = config.multiProject.activeProject;
53
73
  const projectConfig = config.multiProject.projects?.[activeProject];
54
74
  if (projectConfig?.externalTools?.github?.repository) {
@@ -59,17 +79,16 @@ async function loadGitHubConfig(): Promise<GitHubConfig | null> {
59
79
  }
60
80
  }
61
81
  }
62
- // Method 3: sync.profiles[defaultProfile]
63
- else if (config.sync?.defaultProfile && config.sync?.profiles) {
82
+ // Method 4: sync.profiles[defaultProfile]
83
+ if (!owner && !repo && config.sync?.defaultProfile && config.sync?.profiles) {
64
84
  const profile = config.sync.profiles[config.sync.defaultProfile];
65
85
  if (profile?.config?.owner && profile?.config?.repo) {
66
86
  owner = profile.config.owner;
67
87
  repo = profile.config.repo;
68
88
  }
69
89
  }
70
- // Method 4 (v1.0.46): First GitHub profile if no defaultProfile is set
71
- // This handles the common case where user has profiles but forgot to set defaultProfile
72
- else if (config.sync?.profiles) {
90
+ // Method 5: First GitHub profile if no defaultProfile is set
91
+ if (!owner && !repo && config.sync?.profiles) {
73
92
  const profileNames = Object.keys(config.sync.profiles);
74
93
  for (const name of profileNames) {
75
94
  const profile = config.sync.profiles[name];
@@ -86,7 +105,7 @@ async function loadGitHubConfig(): Promise<GitHubConfig | null> {
86
105
  }
87
106
  }
88
107
 
89
- // Fallback: detect from git remote
108
+ // Step 6: Git remote detection (lowest precedence)
90
109
  if (!owner || !repo) {
91
110
  try {
92
111
  const { execSync } = await import('child_process');
@@ -95,14 +114,13 @@ async function loadGitHubConfig(): Promise<GitHubConfig | null> {
95
114
  cwd: projectRoot
96
115
  }).trim();
97
116
 
98
- // Parse GitHub URL (HTTPS or SSH)
99
117
  const match = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
100
118
  if (match) {
101
119
  owner = owner || match[1];
102
120
  repo = repo || match[2];
103
121
  }
104
122
  } catch {
105
- // Git detection failed, continue with what we have
123
+ // Git detection failed
106
124
  }
107
125
  }
108
126
 
@@ -121,6 +139,10 @@ async function loadGitHubConfig(): Promise<GitHubConfig | null> {
121
139
  return { owner, repo, token };
122
140
  }
123
141
 
142
+ async function loadGitHubConfig(): Promise<GitHubConfig | null> {
143
+ return resolveGitHubConfig();
144
+ }
145
+
124
146
  async function main() {
125
147
  const args = process.argv.slice(2);
126
148
 
@@ -131,7 +153,7 @@ async function main() {
131
153
  console.log(' feature-id Feature ID (e.g., FS-062)');
132
154
  console.log('');
133
155
  console.log('Environment:');
134
- console.log(' GITHUB_TOKEN Required - GitHub personal access token');
156
+ console.log(' GITHUB_TOKEN Required - GitHub PAT with repo scope');
135
157
  console.log('');
136
158
  console.log('Example:');
137
159
  console.log(' GITHUB_TOKEN=ghp_xxx node github-feature-sync-cli.js FS-062');
@@ -8,13 +8,13 @@ class GitHubGraphQLClient {
8
8
  * Tries user first, falls back to organization.
9
9
  */
10
10
  async getOwnerNodeId(login) {
11
- const userQuery = `query { user(login: "${login}") { id } }`;
12
- const userResult = await this.executeGraphQL(userQuery);
11
+ const userQuery = `query($login: String!) { user(login: $login) { id } }`;
12
+ const userResult = await this.executeGraphQL(userQuery, { login });
13
13
  if (userResult.data?.user && userResult.data.user.id) {
14
14
  return userResult.data.user.id;
15
15
  }
16
- const orgQuery = `query { organization(login: "${login}") { id } }`;
17
- const orgResult = await this.executeGraphQL(orgQuery);
16
+ const orgQuery = `query($login: String!) { organization(login: $login) { id } }`;
17
+ const orgResult = await this.executeGraphQL(orgQuery, { login });
18
18
  if (orgResult.data?.organization && orgResult.data.organization.id) {
19
19
  return orgResult.data.organization.id;
20
20
  }
@@ -24,12 +24,12 @@ class GitHubGraphQLClient {
24
24
  * Create a new GitHub Projects V2 board.
25
25
  */
26
26
  async createProjectV2(ownerId, title) {
27
- const query = `mutation {
28
- createProjectV2(input: { ownerId: "${ownerId}", title: "${title}" }) {
27
+ const query = `mutation($ownerId: ID!, $title: String!) {
28
+ createProjectV2(input: { ownerId: $ownerId, title: $title }) {
29
29
  projectV2 { id number }
30
30
  }
31
31
  }`;
32
- const result = await this.executeGraphQL(query);
32
+ const result = await this.executeGraphQL(query, { ownerId, title });
33
33
  const project = result.data?.createProjectV2?.projectV2;
34
34
  return { id: project.id, number: project.number };
35
35
  }
@@ -38,12 +38,12 @@ class GitHubGraphQLClient {
38
38
  * Returns the project item ID. Idempotent.
39
39
  */
40
40
  async addProjectV2Item(projectId, contentId) {
41
- const query = `mutation {
42
- addProjectV2ItemById(input: { projectId: "${projectId}", contentId: "${contentId}" }) {
41
+ const query = `mutation($projectId: ID!, $contentId: ID!) {
42
+ addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
43
43
  item { id }
44
44
  }
45
45
  }`;
46
- const result = await this.executeGraphQL(query);
46
+ const result = await this.executeGraphQL(query, { projectId, contentId });
47
47
  const item = result.data?.addProjectV2ItemById?.item;
48
48
  return item.id;
49
49
  }
@@ -51,24 +51,29 @@ class GitHubGraphQLClient {
51
51
  * Update a field value on a Projects V2 item.
52
52
  */
53
53
  async updateItemFieldValue(projectId, itemId, fieldId, value) {
54
- const query = `mutation {
54
+ const query = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
55
55
  updateProjectV2ItemFieldValue(input: {
56
- projectId: "${projectId}",
57
- itemId: "${itemId}",
58
- fieldId: "${fieldId}",
59
- value: { singleSelectOptionId: "${value.singleSelectOptionId}" }
56
+ projectId: $projectId,
57
+ itemId: $itemId,
58
+ fieldId: $fieldId,
59
+ value: { singleSelectOptionId: $optionId }
60
60
  }) {
61
61
  projectV2Item { id }
62
62
  }
63
63
  }`;
64
- await this.executeGraphQL(query);
64
+ await this.executeGraphQL(query, {
65
+ projectId,
66
+ itemId,
67
+ fieldId,
68
+ optionId: value.singleSelectOptionId
69
+ });
65
70
  }
66
71
  /**
67
72
  * Get field definitions for a Projects V2 board.
68
73
  */
69
74
  async getProjectFields(projectId) {
70
- const query = `query {
71
- node(id: "${projectId}") {
75
+ const query = `query($nodeId: ID!) {
76
+ node(id: $nodeId) {
72
77
  ... on ProjectV2 {
73
78
  fields(first: 50) {
74
79
  nodes {
@@ -82,7 +87,7 @@ class GitHubGraphQLClient {
82
87
  }
83
88
  }
84
89
  }`;
85
- const result = await this.executeGraphQL(query);
90
+ const result = await this.executeGraphQL(query, { nodeId: projectId });
86
91
  const fields = result.data?.node?.fields?.nodes || [];
87
92
  return fields.map((f) => {
88
93
  const field = { id: f.id, name: f.name };
@@ -94,10 +99,14 @@ class GitHubGraphQLClient {
94
99
  }
95
100
  /**
96
101
  * Execute a GraphQL query via `gh api graphql`.
102
+ * All variables are passed via `-F` flags to prevent injection.
97
103
  */
98
- executeGraphQL(query) {
104
+ executeGraphQL(query, variables = {}) {
99
105
  return new Promise((resolve, reject) => {
100
106
  const args = ["api", "graphql", "-f", `query=${query}`];
107
+ for (const [key, value] of Object.entries(variables)) {
108
+ args.push("-F", `${key}=${value}`);
109
+ }
101
110
  const opts = {};
102
111
  if (this.token) {
103
112
  opts.env = { ...process.env, GH_TOKEN: this.token };