@link-assistant/hive-mind 2.11.1 → 2.11.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.11.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 2777cf5: Stop `/task --ci-cd` and `/fix --ci-cd` from listing the same workflow many times in the generated issue (issue #2125). When the latest default-branch commit has no workflow runs — typical for release commits — the collector falls back to the recent runs of the default branch, which span many commits; every one of them became a table row, so `link-assistant/agent#287` listed two workflows twenty times and reported "20 (9 not passing)". `dedupeRunsByWorkflow()` now keeps only the most recent run of each workflow (by `workflow_id`, then `path`/`name`, resolved with `created_at`/`run_attempt`/`id`), the failure summary counts the same deduplicated set, and the branch-fallback table gained a Commit column because its rows can come from different commits. The fallback fetches 100 runs instead of 20 so a rarely-run workflow still appears after collapsing, and `prepareCiCdIssue()` logs how many runs it collapsed.
8
+
3
9
  ## 2.11.1
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.11.1",
3
+ "version": "2.11.2",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { spawn } from 'child_process';
7
- import { CI_CD_ISSUE_LABELS, CI_CD_ISSUE_TYPE, buildCiCdIssueBody, buildCiCdIssueTitle } from './fix.ci-cd.lib.mjs';
7
+ import { CI_CD_ISSUE_LABELS, CI_CD_ISSUE_TYPE, buildCiCdIssueBody, buildCiCdIssueTitle, dedupeRunsByWorkflow } from './fix.ci-cd.lib.mjs';
8
8
  import { createTaskIssue } from './task.issue-creation.lib.mjs';
9
9
 
10
10
  function runCommand(command, args, options = {}) {
@@ -70,7 +70,9 @@ async function getLatestCommit(repository, branch, run, warn) {
70
70
  }
71
71
  }
72
72
 
73
- const RUNS_JQ = '[.workflow_runs[] | {name: .name, status: .status, conclusion: .conclusion, html_url: .html_url, head_sha: .head_sha}]';
73
+ // `workflow_id`, `created_at` and `run_attempt` are what let
74
+ // `dedupeRunsByWorkflow` keep the latest run of each workflow (issue #2125).
75
+ const RUNS_JQ = '[.workflow_runs[] | {id: .id, name: .name, workflow_id: .workflow_id, path: .path, status: .status, conclusion: .conclusion, html_url: .html_url, head_sha: .head_sha, created_at: .created_at, run_attempt: .run_attempt}]';
74
76
 
75
77
  async function getRunsForCommit(repository, sha, run, warn) {
76
78
  if (!sha) return [];
@@ -87,7 +89,7 @@ async function getRunsForCommit(repository, sha, run, warn) {
87
89
  async function getRecentBranchRuns(repository, branch, run, warn) {
88
90
  if (!branch) return [];
89
91
  try {
90
- const json = await commandOutput(run, 'gh', ['api', `repos/${repository.fullName}/actions/runs?branch=${encodeURIComponent(branch)}&per_page=20`, '--jq', RUNS_JQ]);
92
+ const json = await commandOutput(run, 'gh', ['api', `repos/${repository.fullName}/actions/runs?branch=${encodeURIComponent(branch)}&per_page=100`, '--jq', RUNS_JQ]);
91
93
  const parsed = JSON.parse(json);
92
94
  return Array.isArray(parsed) ? parsed : [];
93
95
  } catch (error) {
@@ -96,7 +98,7 @@ async function getRecentBranchRuns(repository, branch, run, warn) {
96
98
  }
97
99
  }
98
100
 
99
- export async function prepareCiCdIssue({ repository, run = runCommand, warn = message => console.warn(message) }) {
101
+ export async function prepareCiCdIssue({ repository, run = runCommand, warn = message => console.warn(message), log = null }) {
100
102
  const [languages, defaultBranch] = await Promise.all([detectLanguages(repository, run, warn), getDefaultBranch(repository, run, warn)]);
101
103
  const commit = await getLatestCommit(repository, defaultBranch, run, warn);
102
104
  let runs = await getRunsForCommit(repository, commit?.sha, run, warn);
@@ -110,11 +112,22 @@ export async function prepareCiCdIssue({ repository, run = runCommand, warn = me
110
112
  }
111
113
  }
112
114
 
115
+ // The branch fallback returns every run of every workflow across many
116
+ // commits; the issue must list one row per workflow (issue #2125).
117
+ const fetchedRuns = runs.length;
118
+ runs = dedupeRunsByWorkflow(runs);
119
+ const duplicates = fetchedRuns - runs.length;
120
+ if (duplicates > 0 && typeof log === 'function') {
121
+ log(`ℹ️ Collapsed ${duplicates} older CI/CD run(s) — keeping the latest run of each workflow (${runs.length} workflow(s), source: ${runsSource}).`);
122
+ }
123
+
113
124
  return {
114
125
  repository,
115
126
  defaultBranch,
116
127
  commit,
117
128
  runs,
129
+ fetchedRuns,
130
+ duplicateRuns: duplicates,
118
131
  languages,
119
132
  runsSource,
120
133
  title: buildCiCdIssueTitle(),
@@ -123,7 +136,7 @@ export async function prepareCiCdIssue({ repository, run = runCommand, warn = me
123
136
  }
124
137
 
125
138
  export async function createCiCdIssue({ repository, prepared = null, run = runCommand, log = null, warn = message => console.warn(message) }) {
126
- const issueDraft = prepared || (await prepareCiCdIssue({ repository, run, warn }));
139
+ const issueDraft = prepared || (await prepareCiCdIssue({ repository, run, warn, log }));
127
140
  const issue = await createTaskIssue({
128
141
  repository,
129
142
  title: issueDraft.title,
@@ -249,27 +249,95 @@ export function buildTemplatesSection(languages) {
249
249
  return lines.join('\n');
250
250
  }
251
251
 
252
- /** Render the CI/CD runs section from the GitHub Actions API payload. */
253
- export function buildRunsSection(runs, { emptyMessage } = {}) {
252
+ /**
253
+ * Stable identity of the workflow a run belongs to (issue #2125).
254
+ *
255
+ * `workflow_id` is the authoritative key: two workflow files may share the same
256
+ * display `name`, and one workflow file may be renamed between runs. The name
257
+ * (and `path`) are only fallbacks for payloads that omit the id.
258
+ */
259
+ export function runWorkflowKey(run) {
260
+ const workflowId = run?.workflow_id ?? run?.workflowId;
261
+ if (workflowId !== undefined && workflowId !== null && workflowId !== '') return `id:${workflowId}`;
262
+ if (run?.path) return `path:${run.path}`;
263
+ const name = run?.name || run?.workflowName;
264
+ // A run with no identity at all cannot be proven to be a duplicate.
265
+ return name ? `name:${String(name).toLowerCase()}` : null;
266
+ }
267
+
268
+ /** Recency of a run: newest first, using created_at, then attempt, then id. */
269
+ function compareRunRecency(a, b) {
270
+ const timeA = Date.parse(a?.created_at || a?.run_started_at || '') || 0;
271
+ const timeB = Date.parse(b?.created_at || b?.run_started_at || '') || 0;
272
+ if (timeA !== timeB) return timeB - timeA;
273
+ const attemptA = Number(a?.run_attempt) || 0;
274
+ const attemptB = Number(b?.run_attempt) || 0;
275
+ if (attemptA !== attemptB) return attemptB - attemptA;
276
+ return (Number(b?.id) || 0) - (Number(a?.id) || 0);
277
+ }
278
+
279
+ /**
280
+ * Keep only the most recent run per workflow (issue #2125).
281
+ *
282
+ * When `/fix --ci-cd` falls back to "recent runs on the default branch" the
283
+ * GitHub API returns every run of every workflow across many commits, so the
284
+ * generated issue listed the same two workflows twenty times. One row per
285
+ * workflow — its latest run — is what makes the table actionable.
286
+ *
287
+ * Order of the surviving rows follows the input (the API returns newest first).
288
+ */
289
+ export function dedupeRunsByWorkflow(runs) {
290
+ const list = Array.isArray(runs) ? runs : [];
291
+ const bestByWorkflow = new Map(); // key -> { run, index }
292
+ list.forEach((run, index) => {
293
+ const key = runWorkflowKey(run) ?? `index:${index}`;
294
+ const existing = bestByWorkflow.get(key);
295
+ if (!existing || compareRunRecency(run, existing.run) < 0) {
296
+ bestByWorkflow.set(key, { run, index: existing ? existing.index : index });
297
+ }
298
+ });
299
+ return [...bestByWorkflow.values()].sort((a, b) => a.index - b.index).map(entry => entry.run);
300
+ }
301
+
302
+ /** How many rows `dedupeRunsByWorkflow` would drop (for verbose logging). */
303
+ export function countDuplicateRuns(runs) {
254
304
  const list = Array.isArray(runs) ? runs : [];
305
+ return list.length - dedupeRunsByWorkflow(list).length;
306
+ }
307
+
308
+ /**
309
+ * Render the CI/CD runs section from the GitHub Actions API payload.
310
+ *
311
+ * Runs are deduplicated per workflow (issue #2125). Pass `includeCommit: true`
312
+ * when the rows may come from different commits (the default-branch fallback)
313
+ * so it stays visible which commit each run belongs to.
314
+ */
315
+ export function buildRunsSection(runs, { emptyMessage, includeCommit = false } = {}) {
316
+ const list = dedupeRunsByWorkflow(runs);
255
317
  if (list.length === 0) {
256
318
  return emptyMessage || 'No CI/CD runs were found for the latest default-branch commit.';
257
319
  }
258
- const header = '| Workflow | Status | Conclusion | Run |\n| --- | --- | --- | --- |';
320
+ const header = includeCommit ? '| Workflow | Status | Conclusion | Commit | Run |\n| --- | --- | --- | --- | --- |' : '| Workflow | Status | Conclusion | Run |\n| --- | --- | --- | --- |';
259
321
  const rows = list.map(run => {
260
322
  const name = run.name || run.workflowName || 'unknown';
261
323
  const status = run.status || 'unknown';
262
324
  const conclusion = run.conclusion || (status === 'completed' ? 'unknown' : 'in_progress');
263
325
  const url = run.html_url || run.url || '';
264
326
  const runLabel = url ? `[run](${url})` : '—';
265
- return `| ${name} | ${status} | ${conclusion} | ${runLabel} |`;
327
+ if (!includeCommit) return `| ${name} | ${status} | ${conclusion} | ${runLabel} |`;
328
+ const sha = shortSha(run.head_sha);
329
+ return `| ${name} | ${status} | ${conclusion} | ${sha ? `\`${sha}\`` : '—'} | ${runLabel} |`;
266
330
  });
267
331
  return [header, ...rows].join('\n');
268
332
  }
269
333
 
270
- /** Count the runs that did not pass (failure/cancelled/timed_out/etc.). */
334
+ /**
335
+ * Count the runs that did not pass (failure/cancelled/timed_out/etc.).
336
+ * Counts one run per workflow so the summary matches the rendered table
337
+ * (issue #2125).
338
+ */
271
339
  export function summarizeRunFailures(runs) {
272
- const list = Array.isArray(runs) ? runs : [];
340
+ const list = dedupeRunsByWorkflow(runs);
273
341
  const passing = new Set(['success', 'neutral', 'skipped']);
274
342
  const failing = list.filter(run => {
275
343
  const conclusion = (run.conclusion || '').toLowerCase();
@@ -375,7 +443,10 @@ export const CI_CD_ISSUE_LABELS = Object.freeze(['bug']);
375
443
  */
376
444
  export function buildCiCdIssueBody({ repository, defaultBranch, commit, runs, languages, runsSource = 'commit', omittedOptions = FIX_FORWARDED_SOLVE_OPTIONS }) {
377
445
  const { sortedTemplates } = mapLanguagesToTemplates(languages);
378
- const { total, failing } = summarizeRunFailures(runs);
446
+ // One row per workflow: the branch fallback returns every run of every
447
+ // workflow across many commits (issue #2125).
448
+ const uniqueRuns = dedupeRunsByWorkflow(runs);
449
+ const { total, failing } = summarizeRunFailures(uniqueRuns);
379
450
 
380
451
  const commitLine = commit?.sha ? `\`${shortSha(commit.sha)}\`${commit.url ? ` ([commit](${commit.url}))` : ''}${commit.message ? ` — ${String(commit.message).split('\n')[0]}` : ''}` : 'unknown';
381
452
 
@@ -385,7 +456,7 @@ export function buildCiCdIssueBody({ repository, defaultBranch, commit, runs, la
385
456
  const runsHeading = runsSource === 'branch' ? `Recent CI/CD runs on \`${defaultBranch || 'default branch'}\`` : 'Latest default-branch CI/CD runs';
386
457
  const runsEmptyMessage = runsSource === 'branch' ? `No recent CI/CD runs were found on \`${defaultBranch || 'the default branch'}\`.` : 'No CI/CD runs were found for the latest default-branch commit.';
387
458
 
388
- const sections = [`### ${runsHeading}`, '', buildRunsSection(runs, { emptyMessage: runsEmptyMessage }), '', buildStandardPrompt({ templatesSorted: sortedTemplates, omittedOptions }), '', '---', '', '<details>', '<summary>Context collected by <code>/fix --ci-cd</code></summary>', '', `- **Repository:** [${repository?.fullName}](${repository?.url})`, `- **Default branch:** \`${defaultBranch || 'unknown'}\``, `- **Latest commit:** ${commitLine}`, `- **CI/CD runs found:** ${total} (${failing} not passing)`, '', '**Detected languages**', '', buildLanguagesSection(languages), '', '**Recommended CI/CD templates**', '', buildTemplatesSection(languages), '', '</details>'];
459
+ const sections = [`### ${runsHeading}`, '', buildRunsSection(uniqueRuns, { emptyMessage: runsEmptyMessage, includeCommit: runsSource === 'branch' }), '', buildStandardPrompt({ templatesSorted: sortedTemplates, omittedOptions }), '', '---', '', '<details>', '<summary>Context collected by <code>/fix --ci-cd</code></summary>', '', `- **Repository:** [${repository?.fullName}](${repository?.url})`, `- **Default branch:** \`${defaultBranch || 'unknown'}\``, `- **Latest commit:** ${commitLine}`, `- **CI/CD runs found:** ${total} (${failing} not passing)`, '', '**Detected languages**', '', buildLanguagesSection(languages), '', '**Recommended CI/CD templates**', '', buildTemplatesSection(languages), '', '</details>'];
389
460
 
390
461
  return sections.join('\n');
391
462
  }
package/src/fix.mjs CHANGED
@@ -83,7 +83,7 @@ async function main() {
83
83
  const repository = parsed.repository;
84
84
  console.log(`🔧 /fix --ci-cd for ${repository.fullName}`);
85
85
 
86
- const prepared = await prepareCiCdIssue({ repository });
86
+ const prepared = await prepareCiCdIssue({ repository, log: message => console.log(` ${message}`) });
87
87
  const { defaultBranch, commit, runs, runsSource, title, body } = prepared;
88
88
 
89
89
  const { total, failing } = summarizeRunFailures(runs);