@link-assistant/hive-mind 2.7.4 → 2.8.1

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.
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Pure helpers for the `/fix --ci-cd` command (issue #1733).
3
+ *
4
+ * `/fix --ci-cd <repository>` automatically:
5
+ * 1. detects the languages used in the target repository,
6
+ * 2. inspects the latest default-branch commit and its CI/CD runs,
7
+ * 3. creates a remediation issue (mirroring the `/task` issue-creation flow)
8
+ * that links the language-appropriate CI/CD pipeline templates and the
9
+ * CI/CD best-practices guide, and
10
+ * 4. hands the issue off to
11
+ * `/solve --development-log --deep-analysis --auto-merge`, forwarding every
12
+ * option that `/fix` itself does not consume (e.g. --tool, --model,
13
+ * --think).
14
+ *
15
+ * The issue title and body are taken from the standard prompt in
16
+ * https://github.com/link-assistant/web-capture/issues/139, omitting the
17
+ * its retired case-study paragraph in favor of `--development-log` and omitting
18
+ * paragraphs that `--deep-analysis` already injects into the AI prompt (issue
19
+ * #1733) — see `buildStandardPromptParagraphs` below.
20
+ *
21
+ * Everything that does not touch the network or the filesystem lives here so it
22
+ * can be unit-tested without GitHub access.
23
+ */
24
+
25
+ import { KEEP_WORKING_PROMPT } from './solve.keep-working.detect.lib.mjs';
26
+
27
+ /**
28
+ * Canonical mapping from GitHub Linguist language names to the
29
+ * link-foundation AI-driven-development pipeline templates.
30
+ *
31
+ * Order in this array is the stable tie-breaker when two languages contribute
32
+ * an equal number of bytes. The PHP template was added per issue #1733.
33
+ */
34
+ export const CI_CD_TEMPLATES = Object.freeze([
35
+ {
36
+ key: 'javascript',
37
+ label: 'JavaScript / TypeScript',
38
+ languages: ['JavaScript', 'TypeScript'],
39
+ repo: 'link-foundation/js-ai-driven-development-pipeline-template',
40
+ },
41
+ {
42
+ key: 'rust',
43
+ label: 'Rust',
44
+ languages: ['Rust'],
45
+ repo: 'link-foundation/rust-ai-driven-development-pipeline-template',
46
+ },
47
+ {
48
+ key: 'python',
49
+ label: 'Python',
50
+ languages: ['Python'],
51
+ repo: 'link-foundation/python-ai-driven-development-pipeline-template',
52
+ },
53
+ {
54
+ key: 'go',
55
+ label: 'Go',
56
+ languages: ['Go'],
57
+ repo: 'link-foundation/go-ai-driven-development-pipeline-template',
58
+ },
59
+ {
60
+ key: 'csharp',
61
+ label: 'C#',
62
+ languages: ['C#'],
63
+ repo: 'link-foundation/csharp-ai-driven-development-pipeline-template',
64
+ },
65
+ {
66
+ key: 'java',
67
+ label: 'Java',
68
+ languages: ['Java'],
69
+ repo: 'link-foundation/java-ai-driven-development-pipeline-template',
70
+ },
71
+ {
72
+ key: 'php',
73
+ label: 'PHP',
74
+ languages: ['PHP'],
75
+ repo: 'link-foundation/php-ai-driven-development-pipeline-template',
76
+ },
77
+ ]);
78
+
79
+ export const CI_CD_BEST_PRACTICES_URL = 'https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.md';
80
+
81
+ /** Build a browser URL for a `owner/repo` slug. */
82
+ export function templateUrl(repo) {
83
+ return `https://github.com/${repo}`;
84
+ }
85
+
86
+ /**
87
+ * Parse a `/fix` repository argument into a normalized descriptor.
88
+ * Returns null when the value is not a GitHub repository URL/shorthand.
89
+ *
90
+ * Self-contained on purpose: keeping this module free of the heavy
91
+ * `github.lib.mjs` import chain lets the pure helpers be unit-tested without
92
+ * network access. Accepts:
93
+ * - https://github.com/owner/repo (with optional .git / trailing slash)
94
+ * - github.com/owner/repo
95
+ * - owner/repo shorthand
96
+ * Rejects anything that points deeper than a repository (issues, pulls, …),
97
+ * contains whitespace, or is otherwise malformed.
98
+ */
99
+ export function parseFixRepository(value) {
100
+ const candidate = String(value || '')
101
+ .trim()
102
+ .replace(/^[<([{]+/, '')
103
+ .replace(/[>\])}.,;:]+$/, '');
104
+ if (!candidate || /\s/.test(candidate)) return null;
105
+
106
+ // Normalize away an optional protocol, then require either a github.com host
107
+ // or a bare `owner/repo` shorthand. Any other host is rejected.
108
+ let withoutProtocol = candidate.replace(/^https?:\/\//i, '');
109
+ const hadProtocol = withoutProtocol !== candidate;
110
+
111
+ let pathPart;
112
+ if (/^github\.com\//i.test(withoutProtocol)) {
113
+ pathPart = withoutProtocol.replace(/^github\.com\//i, '');
114
+ } else if (!hadProtocol && !withoutProtocol.includes('.com/') && !/[^/]+\.[^/]+\//.test(withoutProtocol)) {
115
+ // Bare shorthand like `owner/repo`.
116
+ pathPart = withoutProtocol;
117
+ } else {
118
+ return null;
119
+ }
120
+
121
+ pathPart = pathPart.replace(/\.git$/i, '').replace(/\/+$/, '');
122
+
123
+ const segments = pathPart.split('/').filter(Boolean);
124
+ if (segments.length !== 2) return null;
125
+
126
+ const [owner, repo] = segments;
127
+ if (!/^[A-Za-z0-9._-]+$/.test(owner) || !/^[A-Za-z0-9._-]+$/.test(repo)) return null;
128
+
129
+ return {
130
+ owner,
131
+ repo,
132
+ fullName: `${owner}/${repo}`,
133
+ url: `https://github.com/${owner}/${repo}`,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Normalize the GitHub `/languages` response (a `{ "JavaScript": bytes }` map)
139
+ * or an array of names into a byte-sorted array of `{ name, bytes }`.
140
+ */
141
+ export function normalizeLanguages(input) {
142
+ let entries = [];
143
+ if (Array.isArray(input)) {
144
+ entries = input.map(name => [String(name), 0]);
145
+ } else if (input && typeof input === 'object') {
146
+ entries = Object.entries(input).map(([name, bytes]) => [String(name), Number(bytes) || 0]);
147
+ }
148
+ return entries
149
+ .filter(([name]) => name)
150
+ .map(([name, bytes]) => ({ name, bytes }))
151
+ .sort((a, b) => b.bytes - a.bytes || a.name.localeCompare(b.name));
152
+ }
153
+
154
+ /**
155
+ * Map detected languages to CI/CD templates, sorted so that the templates for
156
+ * the most-used languages come first (issue #1733: "links to CI/CD templates
157
+ * should be sorted by detected languages in the target repository").
158
+ *
159
+ * Returns:
160
+ * - sortedTemplates: matched templates ordered by aggregate detected bytes
161
+ * - unmatchedLanguages: detected languages with no template (informational)
162
+ */
163
+ export function mapLanguagesToTemplates(languages) {
164
+ const normalized = normalizeLanguages(languages);
165
+
166
+ const templateByLanguage = new Map();
167
+ for (const template of CI_CD_TEMPLATES) {
168
+ for (const language of template.languages) {
169
+ templateByLanguage.set(language.toLowerCase(), template);
170
+ }
171
+ }
172
+
173
+ const aggregate = new Map(); // template.key -> { template, bytes, languages: [] }
174
+ const unmatchedLanguages = [];
175
+
176
+ for (const { name, bytes } of normalized) {
177
+ const template = templateByLanguage.get(name.toLowerCase());
178
+ if (!template) {
179
+ unmatchedLanguages.push(name);
180
+ continue;
181
+ }
182
+ const existing = aggregate.get(template.key) || { template, bytes: 0, languages: [] };
183
+ existing.bytes += bytes;
184
+ existing.languages.push(name);
185
+ aggregate.set(template.key, existing);
186
+ }
187
+
188
+ const templateOrder = new Map(CI_CD_TEMPLATES.map((template, index) => [template.key, index]));
189
+ const sortedTemplates = [...aggregate.values()].sort((a, b) => b.bytes - a.bytes || templateOrder.get(a.template.key) - templateOrder.get(b.template.key));
190
+
191
+ return { sortedTemplates, unmatchedLanguages };
192
+ }
193
+
194
+ /**
195
+ * Title of the auto-generated remediation issue, taken exactly from the
196
+ * standard template issue https://github.com/link-assistant/web-capture/issues/139
197
+ * (issue #1733: "use title and description exactly"). The issue is created in
198
+ * the target repository itself, so it carries no repository suffix.
199
+ */
200
+ export const CI_CD_ISSUE_TITLE = 'Check for all false positives, false negatives, warnings and errors in CI/CD and fix them all';
201
+
202
+ export function buildCiCdIssueTitle() {
203
+ return CI_CD_ISSUE_TITLE;
204
+ }
205
+
206
+ function shortSha(sha) {
207
+ return String(sha || '').slice(0, 7);
208
+ }
209
+
210
+ /** Render the detected-languages section. */
211
+ export function buildLanguagesSection(languages) {
212
+ const normalized = normalizeLanguages(languages);
213
+ if (normalized.length === 0) {
214
+ return 'No languages were reported by the GitHub Linguist API for this repository.';
215
+ }
216
+ const total = normalized.reduce((sum, { bytes }) => sum + bytes, 0) || 1;
217
+ const lines = normalized.map(({ name, bytes }) => {
218
+ const percent = ((bytes / total) * 100).toFixed(1);
219
+ return `- **${name}** — ${percent}%`;
220
+ });
221
+ return lines.join('\n');
222
+ }
223
+
224
+ /** Render the recommended-templates section, sorted by detected languages. */
225
+ export function buildTemplatesSection(languages) {
226
+ const { sortedTemplates, unmatchedLanguages } = mapLanguagesToTemplates(languages);
227
+ const lines = [];
228
+
229
+ if (sortedTemplates.length === 0) {
230
+ lines.push('No language-specific template matched the detected languages. Review all templates and apply the closest match:');
231
+ lines.push('');
232
+ for (const template of CI_CD_TEMPLATES) {
233
+ lines.push(`- ${template.label}: [${template.repo}](${templateUrl(template.repo)})`);
234
+ }
235
+ } else {
236
+ lines.push('Apply the best practices from these templates, in priority order (most-used language first):');
237
+ lines.push('');
238
+ sortedTemplates.forEach((entry, index) => {
239
+ const detected = entry.languages.join(', ');
240
+ lines.push(`${index + 1}. **${entry.template.label}** — [${entry.template.repo}](${templateUrl(entry.template.repo)}) _(detected: ${detected})_`);
241
+ });
242
+ }
243
+
244
+ if (unmatchedLanguages.length > 0) {
245
+ lines.push('');
246
+ lines.push(`Other detected languages without a dedicated template: ${unmatchedLanguages.join(', ')}.`);
247
+ }
248
+
249
+ return lines.join('\n');
250
+ }
251
+
252
+ /** Render the CI/CD runs section from the GitHub Actions API payload. */
253
+ export function buildRunsSection(runs, { emptyMessage } = {}) {
254
+ const list = Array.isArray(runs) ? runs : [];
255
+ if (list.length === 0) {
256
+ return emptyMessage || 'No CI/CD runs were found for the latest default-branch commit.';
257
+ }
258
+ const header = '| Workflow | Status | Conclusion | Run |\n| --- | --- | --- | --- |';
259
+ const rows = list.map(run => {
260
+ const name = run.name || run.workflowName || 'unknown';
261
+ const status = run.status || 'unknown';
262
+ const conclusion = run.conclusion || (status === 'completed' ? 'unknown' : 'in_progress');
263
+ const url = run.html_url || run.url || '';
264
+ const runLabel = url ? `[run](${url})` : '—';
265
+ return `| ${name} | ${status} | ${conclusion} | ${runLabel} |`;
266
+ });
267
+ return [header, ...rows].join('\n');
268
+ }
269
+
270
+ /** Count the runs that did not pass (failure/cancelled/timed_out/etc.). */
271
+ export function summarizeRunFailures(runs) {
272
+ const list = Array.isArray(runs) ? runs : [];
273
+ const passing = new Set(['success', 'neutral', 'skipped']);
274
+ const failing = list.filter(run => {
275
+ const conclusion = (run.conclusion || '').toLowerCase();
276
+ return run.status === 'completed' && conclusion && !passing.has(conclusion);
277
+ });
278
+ return { total: list.length, failing: failing.length };
279
+ }
280
+
281
+ /**
282
+ * The `/solve` options that `/fix` always turns on. `--development-log`
283
+ * replaces the retired collection paragraph; `--deep-analysis` provides the
284
+ * remaining instructions omitted from the generated issue body (issue #1733).
285
+ */
286
+ export const SOLVE_OPTION_DEVELOPMENT_LOG = '--development-log';
287
+ export const SOLVE_OPTION_DEEP_ANALYSIS = '--deep-analysis';
288
+ export const FIX_FORWARDED_SOLVE_OPTIONS = Object.freeze([SOLVE_OPTION_DEVELOPMENT_LOG, SOLVE_OPTION_DEEP_ANALYSIS]);
289
+
290
+ /**
291
+ * Paragraphs of the standard prompt, quoted from
292
+ * https://github.com/link-assistant/web-capture/issues/139.
293
+ *
294
+ * `providedBy` lists the `/solve` options that already inject an equivalent
295
+ * instruction into the AI prompt (see `buildDeepAnalysisPrompt`). A paragraph
296
+ * is dropped from the issue body when every option that provides it is passed
297
+ * to `/solve`.
298
+ *
299
+ * The deep-analysis wording below is the "bug" variant, which `/solve` emits
300
+ * only when the issue type is Bug — `/fix` therefore creates the issue with
301
+ * that type (see CI_CD_ISSUE_TYPE).
302
+ *
303
+ * The old case-study instruction from the upstream template is intentionally
304
+ * not represented here. `--development-log` is its replacement; generated
305
+ * issues must never offer or restore the superseded folder convention, even
306
+ * when callers request an otherwise unabridged prompt (PR #1929 feedback).
307
+ */
308
+ export const DEBUG_OUTPUT_PARAGRAPH = 'If there is not enough data to find actual root cause, add debug output and verbose mode if not present, that will allow us to find root cause on next iteration.';
309
+ export const REPORT_UPSTREAM_PARAGRAPH = 'If issue related to any other repository/project, where we can report issues on GitHub, please do so. Each issue must contain reproducible examples, workarounds and suggestions for fix the issue in code. Also double check to fully apply requirements to entire codebase, so if we have issue in multiple places, it should be fixed in all them.';
310
+
311
+ /** Build the ordered, tagged paragraphs of the standard prompt. */
312
+ export function buildStandardPromptParagraphs({ templatesSorted } = {}) {
313
+ const templateLinks = (templatesSorted && templatesSorted.length > 0 ? templatesSorted.map(entry => entry.template.repo) : CI_CD_TEMPLATES.map(template => template.repo)).map(repo => `- ${templateUrl(repo)}`).join('\n');
314
+
315
+ return [
316
+ {
317
+ providedBy: [],
318
+ text: `Use all the best practices from CI/CD templates (check full file tree to compare for all GitHub workflow and CI/CD scripts file), if the same issue is found in template report issue also in templates:\n\n${templateLinks}`,
319
+ },
320
+ {
321
+ providedBy: [],
322
+ text: "We should compare all files, so we don't have more CI/CD errors in the future and reuse all the best practices from these templates.",
323
+ },
324
+ {
325
+ providedBy: [SOLVE_OPTION_DEEP_ANALYSIS],
326
+ text: DEBUG_OUTPUT_PARAGRAPH,
327
+ },
328
+ {
329
+ providedBy: [SOLVE_OPTION_DEEP_ANALYSIS],
330
+ text: REPORT_UPSTREAM_PARAGRAPH,
331
+ },
332
+ {
333
+ providedBy: [],
334
+ text: `Follow the CI/CD best practices collected in [${CI_CD_BEST_PRACTICES_URL}](${CI_CD_BEST_PRACTICES_URL}).`,
335
+ },
336
+ {
337
+ // Quoted verbatim from the template; identical to the reinforcement
338
+ // prompt /solve --keep-working-... reuses, so share the single constant.
339
+ providedBy: [],
340
+ text: KEEP_WORKING_PROMPT,
341
+ },
342
+ ];
343
+ }
344
+
345
+ /**
346
+ * The standard remediation prompt, quoted from web-capture#139 with the
347
+ * paragraphs that `omittedOptions` already provide removed.
348
+ */
349
+ export function buildStandardPrompt({ templatesSorted, omittedOptions = FIX_FORWARDED_SOLVE_OPTIONS } = {}) {
350
+ const omitted = new Set(omittedOptions || []);
351
+ return buildStandardPromptParagraphs({ templatesSorted })
352
+ .filter(paragraph => paragraph.providedBy.length === 0 || !paragraph.providedBy.every(option => omitted.has(option)))
353
+ .map(paragraph => paragraph.text)
354
+ .join('\n\n');
355
+ }
356
+
357
+ /**
358
+ * Issue type and label of the generated issue.
359
+ *
360
+ * `/solve --deep-analysis` only emits the root-cause / debug-output /
361
+ * report-upstream instructions — the paragraphs this body omits — when the
362
+ * issue type is Bug (see `isBugIssueType` in development-log.lib.mjs). Creating
363
+ * the issue as a Bug is therefore what makes the omission lossless.
364
+ */
365
+ export const CI_CD_ISSUE_TYPE = 'Bug';
366
+ export const CI_CD_ISSUE_LABELS = Object.freeze(['bug']);
367
+
368
+ /**
369
+ * Build the full Markdown body of the auto-generated remediation issue.
370
+ *
371
+ * The body mirrors the template issue's own description: the CI/CD runs of the
372
+ * latest default-branch commit first, then the standard prompt. The data `/fix`
373
+ * collected to build it (commit, languages, template ranking) follows as a
374
+ * collapsed context block so it stays available without displacing the prompt.
375
+ */
376
+ export function buildCiCdIssueBody({ repository, defaultBranch, commit, runs, languages, runsSource = 'commit', omittedOptions = FIX_FORWARDED_SOLVE_OPTIONS }) {
377
+ const { sortedTemplates } = mapLanguagesToTemplates(languages);
378
+ const { total, failing } = summarizeRunFailures(runs);
379
+
380
+ const commitLine = commit?.sha ? `\`${shortSha(commit.sha)}\`${commit.url ? ` ([commit](${commit.url}))` : ''}${commit.message ? ` — ${String(commit.message).split('\n')[0]}` : ''}` : 'unknown';
381
+
382
+ // When the exact latest commit produced no runs (common for release/tag
383
+ // commits), `/fix` falls back to the most recent runs on the default branch
384
+ // so the issue stays actionable. Label the source honestly.
385
+ const runsHeading = runsSource === 'branch' ? `Recent CI/CD runs on \`${defaultBranch || 'default branch'}\`` : 'Latest default-branch CI/CD runs';
386
+ 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
+
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>'];
389
+
390
+ return sections.join('\n');
391
+ }
392
+
393
+ /**
394
+ * Flags that `/fix` consumes itself and must NOT be forwarded to `/solve`.
395
+ * Boolean flags only — they never take a value.
396
+ */
397
+ export const FIX_OWNED_BOOLEAN_FLAGS = Object.freeze(['--ci-cd', '--dry-run', '--no-solve', '--solve', '--no-auto-solve', '--help', '-h', '--version']);
398
+
399
+ /**
400
+ * Partition raw CLI args into the options `/fix` consumes and the passthrough
401
+ * args forwarded to `/solve`. Unknown flags (and their values) are preserved in
402
+ * order so that `--tool`, `--model`, `--think`, etc. reach `/solve` untouched.
403
+ */
404
+ export function partitionFixArgs(rawArgs) {
405
+ const args = Array.isArray(rawArgs) ? rawArgs : [];
406
+ const result = {
407
+ repository: null,
408
+ repositoryRaw: null,
409
+ ciCd: false,
410
+ dryRun: false,
411
+ runSolve: true,
412
+ help: false,
413
+ version: false,
414
+ passthrough: [],
415
+ };
416
+
417
+ for (const arg of args) {
418
+ if (arg === '--ci-cd') {
419
+ result.ciCd = true;
420
+ continue;
421
+ }
422
+ if (arg === '--dry-run') {
423
+ result.dryRun = true;
424
+ continue;
425
+ }
426
+ if (arg === '--no-solve' || arg === '--no-auto-solve') {
427
+ result.runSolve = false;
428
+ continue;
429
+ }
430
+ if (arg === '--solve') {
431
+ result.runSolve = true;
432
+ continue;
433
+ }
434
+ if (arg === '--help' || arg === '-h') {
435
+ result.help = true;
436
+ continue;
437
+ }
438
+ if (arg === '--version') {
439
+ result.version = true;
440
+ continue;
441
+ }
442
+ // First bare GitHub repository argument becomes the target.
443
+ if (!result.repository && !arg.startsWith('-')) {
444
+ const repository = parseFixRepository(arg);
445
+ if (repository) {
446
+ result.repository = repository;
447
+ result.repositoryRaw = arg;
448
+ continue;
449
+ }
450
+ }
451
+ result.passthrough.push(arg);
452
+ }
453
+
454
+ return result;
455
+ }
456
+
457
+ /**
458
+ * The options `/fix` always turns on when handing the issue to `/solve`
459
+ * (issue #1733: "do similar to what `/solve --development-log --deep-analysis
460
+ * --auto-merge`"). `--development-log` replaces the superseded case-study
461
+ * workflow, while `--deep-analysis` re-injects the prompt paragraphs
462
+ * `buildCiCdIssueBody` conditionally omits from the issue text.
463
+ */
464
+ export const FIX_SOLVE_OPTIONS = Object.freeze([SOLVE_OPTION_DEVELOPMENT_LOG, SOLVE_OPTION_DEEP_ANALYSIS, '--auto-merge']);
465
+
466
+ /**
467
+ * Build the argv passed to `solve.mjs`: the created issue URL, the options
468
+ * `/fix` always enables, and every forwarded option. An option the caller
469
+ * already passed through is not duplicated.
470
+ */
471
+ export function buildSolveArgs({ issueUrl, passthrough = [] }) {
472
+ const args = [issueUrl];
473
+ for (const option of FIX_SOLVE_OPTIONS) {
474
+ if (!passthrough.includes(option)) {
475
+ args.push(option);
476
+ }
477
+ }
478
+ args.push(...passthrough);
479
+ return args;
480
+ }