@link-assistant/hive-mind 1.69.18 → 1.71.0
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 +36 -0
- package/package.json +1 -1
- package/src/gemini.lib.mjs +113 -24
- package/src/solve.config.lib.mjs +19 -0
- package/src/telegram-merge-command.lib.mjs +84 -3
- package/src/telegram-merge-queue.lib.mjs +247 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.71.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- aacdb06: Make the `--tool gemini` integration produce meaningful JSON output and reach
|
|
8
|
+
feature parity with `--tool claude` / `--tool codex`. Resolves #1809.
|
|
9
|
+
- The wrapper now feeds the prompt to gemini-cli through `command-stream`'s
|
|
10
|
+
`stdin` option instead of `cat <prompt-file> | gemini`, so the upstream
|
|
11
|
+
non-zero exit code is no longer swallowed by the pipeline.
|
|
12
|
+
- A new `detectGeminiPlainTextError` helper surfaces gemini-cli's plain-text
|
|
13
|
+
failures (auth required, quota exceeded, invalid model, unknown argument,
|
|
14
|
+
fatal error) as structured wrapper errors so headless callers stop seeing
|
|
15
|
+
silent `success: true` runs when authentication is missing. Tracked upstream
|
|
16
|
+
in [`google-gemini/gemini-cli`'s `validateNonInteractiveAuth`](https://github.com/google-gemini/gemini-cli/blob/main/packages/cli/src/validateNonInterActiveAuth.ts);
|
|
17
|
+
see `docs/case-studies/issue-1809/upstream-issue-draft.md` for the proposed
|
|
18
|
+
upstream fix.
|
|
19
|
+
- A run that emits zero `init`/`message`/`tool_use`/`result` JSONL events is
|
|
20
|
+
now classified as a failure regardless of exit code, so empty runs cannot be
|
|
21
|
+
reported as success anymore.
|
|
22
|
+
- New optional flags wired through to gemini-cli: `--gemini-sandbox`
|
|
23
|
+
(`--sandbox`), `--gemini-extensions` (`--extensions`),
|
|
24
|
+
`--gemini-include-directories` (`--include-directories`, in addition to
|
|
25
|
+
`tempDir`/`workspaceTmpDir` which are always included), and
|
|
26
|
+
`--gemini-allowed-mcp-servers` (`--allowed-mcp-server-names`). `--verbose`
|
|
27
|
+
now also toggles gemini-cli's own `--debug` flag.
|
|
28
|
+
- New tests in `tests/test-gemini-support.mjs` lock in plain-text auth-error
|
|
29
|
+
surfacing, zero-event failure detection, and the verbose/include-directories
|
|
30
|
+
argv plumbing.
|
|
31
|
+
- Case study published in `docs/case-studies/issue-1809/`.
|
|
32
|
+
|
|
33
|
+
## 1.70.0
|
|
34
|
+
|
|
35
|
+
### Minor Changes
|
|
36
|
+
|
|
37
|
+
- 35dc089: Add `--auto-resolve` to the `/merge` Telegram command. After the normal queue finishes, the bot now iterates every PR that was skipped because of merge conflicts and dispatches a `solve <pr-url> --auto-merge` session through `start-screen` — the same path other commands use — so conflict resolution runs with the default `sonnet` model and the PR is merged once the session finishes. Each PR/issue reference in the `/merge` progress and final messages is now rendered as a clickable MarkdownV2 link to the actual pull request or issue. Resolves #1805.
|
|
38
|
+
|
|
3
39
|
## 1.69.18
|
|
4
40
|
|
|
5
41
|
### Patch Changes
|
package/package.json
CHANGED
package/src/gemini.lib.mjs
CHANGED
|
@@ -8,9 +8,6 @@ if (typeof globalThis.use === 'undefined') {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
const { $ } = await use('command-stream');
|
|
11
|
-
const fs = (await use('fs')).promises;
|
|
12
|
-
const path = (await use('path')).default;
|
|
13
|
-
const os = (await use('os')).default;
|
|
14
11
|
|
|
15
12
|
import { log } from './lib.mjs';
|
|
16
13
|
import { reportError } from './sentry.lib.mjs';
|
|
@@ -24,6 +21,80 @@ import { getCumulativeContextInputTokens, toTokenCount } from './context-fill.li
|
|
|
24
21
|
|
|
25
22
|
const shellQuote = value => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
26
23
|
|
|
24
|
+
// Patterns gemini-cli prints to stdout/stderr when no JSONL event can be emitted.
|
|
25
|
+
// Issue #1809: validateNonInteractiveAuth in gemini-cli bypasses the structured
|
|
26
|
+
// stream-json error path; we surface those plain-text failures ourselves until
|
|
27
|
+
// the upstream fix lands. See docs/case-studies/issue-1809/upstream-issue-draft.md.
|
|
28
|
+
const GEMINI_PLAIN_TEXT_ERROR_PATTERNS = [
|
|
29
|
+
{ type: 'AuthenticationRequired', regex: /Please set an Auth method/i },
|
|
30
|
+
{ type: 'AuthenticationRequired', regex: /authentication (?:failed|required)/i },
|
|
31
|
+
{ type: 'AuthenticationRequired', regex: /invalid (?:api[_ ]?key|credentials)/i },
|
|
32
|
+
{ type: 'QuotaExceeded', regex: /quota (?:exceeded|reached)/i },
|
|
33
|
+
{ type: 'InvalidModel', regex: /(?:invalid|unknown) model/i },
|
|
34
|
+
{ type: 'InvalidArgument', regex: /Unknown (?:argument|option)/i },
|
|
35
|
+
{ type: 'FatalError', regex: /^Error:\s/m },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export const detectGeminiPlainTextError = text => {
|
|
39
|
+
if (!text || typeof text !== 'string') return null;
|
|
40
|
+
for (const { type, regex } of GEMINI_PLAIN_TEXT_ERROR_PATTERNS) {
|
|
41
|
+
const match = text.match(regex);
|
|
42
|
+
if (match) {
|
|
43
|
+
const line = (text.split(/\r?\n/).find(l => regex.test(l)) || match[0]).trim();
|
|
44
|
+
return { type, message: line };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Issue #1809: Build the gemini-cli argument list once so verbose mode, includes,
|
|
51
|
+
// sandbox, extensions and MCP allow-list can all be toggled by argv consistently.
|
|
52
|
+
// Returns an array suitable for tagged-template interpolation through command-stream.
|
|
53
|
+
export const buildGeminiArgs = (argv, mappedModel, options = {}) => {
|
|
54
|
+
const { tempDir, workspaceTmpDir } = options;
|
|
55
|
+
const args = ['--output-format', 'stream-json', '--model', mappedModel, '--approval-mode', 'yolo', '--skip-trust'];
|
|
56
|
+
|
|
57
|
+
if (argv?.verbose) args.push('--debug');
|
|
58
|
+
|
|
59
|
+
if (argv?.resume) {
|
|
60
|
+
args.unshift('--resume', String(argv.resume));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const includeDirs = [];
|
|
64
|
+
if (tempDir) includeDirs.push(tempDir);
|
|
65
|
+
if (workspaceTmpDir && workspaceTmpDir !== tempDir) includeDirs.push(workspaceTmpDir);
|
|
66
|
+
if (Array.isArray(argv?.geminiIncludeDirectories)) {
|
|
67
|
+
for (const dir of argv.geminiIncludeDirectories) if (dir) includeDirs.push(String(dir));
|
|
68
|
+
} else if (typeof argv?.geminiIncludeDirectories === 'string' && argv.geminiIncludeDirectories.trim()) {
|
|
69
|
+
includeDirs.push(
|
|
70
|
+
...argv.geminiIncludeDirectories
|
|
71
|
+
.split(',')
|
|
72
|
+
.map(d => d.trim())
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (includeDirs.length > 0) {
|
|
77
|
+
args.push('--include-directories', includeDirs.join(','));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (argv?.geminiSandbox) args.push('--sandbox');
|
|
81
|
+
|
|
82
|
+
const collectList = value => {
|
|
83
|
+
if (!value) return [];
|
|
84
|
+
if (Array.isArray(value)) return value.map(String).filter(Boolean);
|
|
85
|
+
return String(value)
|
|
86
|
+
.split(',')
|
|
87
|
+
.map(v => v.trim())
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
};
|
|
90
|
+
const extensions = collectList(argv?.geminiExtensions);
|
|
91
|
+
if (extensions.length) args.push('--extensions', extensions.join(','));
|
|
92
|
+
const allowedMcp = collectList(argv?.geminiAllowedMcpServers);
|
|
93
|
+
if (allowedMcp.length) args.push('--allowed-mcp-server-names', allowedMcp.join(','));
|
|
94
|
+
|
|
95
|
+
return args;
|
|
96
|
+
};
|
|
97
|
+
|
|
27
98
|
// Model mapping to translate aliases to full model IDs for Gemini.
|
|
28
99
|
// Issue #1473: Uses centralized geminiModels from models/index.mjs.
|
|
29
100
|
export const mapModelToId = model => {
|
|
@@ -304,6 +375,7 @@ export const executeGemini = async params => {
|
|
|
304
375
|
|
|
305
376
|
return await executeGeminiCommand({
|
|
306
377
|
tempDir,
|
|
378
|
+
workspaceTmpDir,
|
|
307
379
|
branchName,
|
|
308
380
|
prompt,
|
|
309
381
|
systemPrompt,
|
|
@@ -321,7 +393,7 @@ export const executeGemini = async params => {
|
|
|
321
393
|
};
|
|
322
394
|
|
|
323
395
|
export const executeGeminiCommand = async params => {
|
|
324
|
-
const { tempDir, branchName, prompt, systemPrompt, argv, log, formatAligned, getResourceSnapshot, forkedRepo, feedbackLines, geminiPath, $, waitForRetryDelay = waitWithCountdown } = params;
|
|
396
|
+
const { tempDir, workspaceTmpDir, branchName, prompt, systemPrompt, argv, log, formatAligned, getResourceSnapshot, forkedRepo, feedbackLines, geminiPath, $, waitForRetryDelay = waitWithCountdown } = params;
|
|
325
397
|
|
|
326
398
|
let retryCount = 0;
|
|
327
399
|
|
|
@@ -348,16 +420,15 @@ export const executeGeminiCommand = async params => {
|
|
|
348
420
|
|
|
349
421
|
const mappedModel = mapModelToId(argv.model || defaultModels.gemini);
|
|
350
422
|
const combinedPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt;
|
|
351
|
-
const promptFile = path.join(os.tmpdir(), `gemini_prompt_${Date.now()}_${process.pid}.txt`);
|
|
352
|
-
await fs.writeFile(promptFile, combinedPrompt);
|
|
353
423
|
|
|
354
|
-
let geminiArgs = `--output-format stream-json --model ${shellQuote(mappedModel)} --approval-mode yolo --skip-trust`;
|
|
355
424
|
if (argv.resume) {
|
|
356
425
|
await log(`🔄 Resuming from Gemini session: ${argv.resume}`);
|
|
357
|
-
geminiArgs = `--resume ${shellQuote(argv.resume)} ${geminiArgs}`;
|
|
358
426
|
}
|
|
359
427
|
|
|
360
|
-
|
|
428
|
+
// Issue #1809: build args via shared helper so verbose/sandbox/include-dirs
|
|
429
|
+
// toggles stay consistent between the logged command and the real invocation.
|
|
430
|
+
const geminiArgList = buildGeminiArgs(argv, mappedModel, { tempDir, workspaceTmpDir });
|
|
431
|
+
const fullCommand = `(cd ${shellQuote(tempDir)} && ${geminiPath} ${geminiArgList.map(shellQuote).join(' ')} <<< <prompt>)`;
|
|
361
432
|
|
|
362
433
|
await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
|
|
363
434
|
await log(fullCommand);
|
|
@@ -370,17 +441,17 @@ export const executeGeminiCommand = async params => {
|
|
|
370
441
|
let limitReached = false;
|
|
371
442
|
let limitResetTime = null;
|
|
372
443
|
let lastMessage = '';
|
|
444
|
+
let plainTextError = null;
|
|
373
445
|
|
|
374
446
|
try {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
:
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
})`cat ${promptFile} | ${geminiPath} --output-format stream-json --model ${mappedModel} --approval-mode yolo --skip-trust`;
|
|
447
|
+
// Issue #1809: feed the prompt directly to gemini-cli stdin via command-stream
|
|
448
|
+
// instead of "cat <file> | gemini" — the pipeline form swallowed the upstream
|
|
449
|
+
// non-zero exit code (no pipefail) and yielded false success: true reports.
|
|
450
|
+
const execCommand = $({
|
|
451
|
+
cwd: tempDir,
|
|
452
|
+
stdin: combinedPrompt,
|
|
453
|
+
mirror: false,
|
|
454
|
+
})`${geminiPath} ${geminiArgList}`;
|
|
384
455
|
|
|
385
456
|
await log(`${formatAligned('📋', 'Command details:', '')}`);
|
|
386
457
|
await log(formatAligned('📂', 'Working directory:', tempDir, 2));
|
|
@@ -406,21 +477,41 @@ export const executeGeminiCommand = async params => {
|
|
|
406
477
|
} else {
|
|
407
478
|
lastMessage = output;
|
|
408
479
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
if (chunk.type === 'stderr') {
|
|
480
|
+
if (!plainTextError) plainTextError = detectGeminiPlainTextError(output);
|
|
481
|
+
} else if (chunk.type === 'stderr') {
|
|
412
482
|
const errorOutput = chunk.data.toString();
|
|
413
483
|
if (errorOutput) {
|
|
414
484
|
await log(errorOutput, { stream: 'stderr' });
|
|
415
485
|
allOutput += errorOutput;
|
|
416
486
|
lastMessage = errorOutput;
|
|
487
|
+
if (!plainTextError) plainTextError = detectGeminiPlainTextError(errorOutput);
|
|
417
488
|
}
|
|
418
489
|
} else if (chunk.type === 'exit') {
|
|
419
490
|
exitCode = chunk.code;
|
|
420
491
|
}
|
|
421
492
|
}
|
|
422
493
|
|
|
423
|
-
|
|
494
|
+
// Issue #1809: require positive evidence that gemini-cli actually ran.
|
|
495
|
+
// An empty JSONL stream + exit 0 (e.g. when stdin is closed early) used
|
|
496
|
+
// to be reported as success: true with messageCount: 0.
|
|
497
|
+
const observedJsonEvents = Object.values(geminiJsonState.eventCounts || {}).reduce((sum, count) => sum + count, 0);
|
|
498
|
+
const hasMeaningfulOutput = observedJsonEvents > 0;
|
|
499
|
+
|
|
500
|
+
// Promote the detected plain-text error into the structured error list
|
|
501
|
+
// so downstream retry / usage-limit detection picks it up.
|
|
502
|
+
if (plainTextError && !geminiJsonState.errorMessages?.some(m => m === plainTextError.message)) {
|
|
503
|
+
geminiJsonState.errorMessages = geminiJsonState.errorMessages || [];
|
|
504
|
+
geminiJsonState.errorMessages.push(plainTextError.message);
|
|
505
|
+
await log(`⚠️ Gemini CLI reported a plain-text error: [${plainTextError.type}] ${plainTextError.message}`, { level: 'warning', verbose: false });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const failedExit = exitCode !== 0;
|
|
509
|
+
const hasJsonError = (geminiJsonState.errorMessages?.length || 0) > 0;
|
|
510
|
+
// Zero JSONL events => the wrapper has nothing to attribute as model work,
|
|
511
|
+
// so this run was effectively a no-op and must be reported as failure.
|
|
512
|
+
const emittedNoEvents = !hasMeaningfulOutput;
|
|
513
|
+
|
|
514
|
+
if (failedExit || hasJsonError || emittedNoEvents) {
|
|
424
515
|
const errorText = geminiJsonState.errorMessages?.length > 0 ? geminiJsonState.errorMessages.join('\n') : allOutput || lastMessage;
|
|
425
516
|
const retryableError = classifyRetryableError(errorText);
|
|
426
517
|
if (retryableError.isRetryable) {
|
|
@@ -522,8 +613,6 @@ export const executeGeminiCommand = async params => {
|
|
|
522
613
|
publicPricingEstimate: null,
|
|
523
614
|
resultSummary: null,
|
|
524
615
|
};
|
|
525
|
-
} finally {
|
|
526
|
-
await fs.unlink(promptFile).catch(() => {});
|
|
527
616
|
}
|
|
528
617
|
};
|
|
529
618
|
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -588,6 +588,25 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
588
588
|
description: 'Experimental and disabled by default. Automatically detect the target issue or pull request language and set the AI work language to English or Russian when one language has more than 51% of all words. Explicit --work-language or --prompt-language takes precedence.',
|
|
589
589
|
default: false,
|
|
590
590
|
},
|
|
591
|
+
// Issue #1809: gemini-cli native flags surfaced as solve.mjs options so users
|
|
592
|
+
// can control sandboxing, extensions and MCP server allow-lists per run.
|
|
593
|
+
'gemini-sandbox': {
|
|
594
|
+
type: 'boolean',
|
|
595
|
+
description: 'Run gemini-cli inside its sandbox (passes --sandbox to gemini-cli). Only used when --tool gemini.',
|
|
596
|
+
default: false,
|
|
597
|
+
},
|
|
598
|
+
'gemini-extensions': {
|
|
599
|
+
type: 'string',
|
|
600
|
+
description: 'Comma-separated list of gemini-cli extensions to load (passes --extensions to gemini-cli). Only used when --tool gemini.',
|
|
601
|
+
},
|
|
602
|
+
'gemini-include-directories': {
|
|
603
|
+
type: 'string',
|
|
604
|
+
description: 'Extra directories to expose to gemini-cli (passes --include-directories to gemini-cli, in addition to tempDir/workspaceTmpDir which are always included). Only used when --tool gemini.',
|
|
605
|
+
},
|
|
606
|
+
'gemini-allowed-mcp-servers': {
|
|
607
|
+
type: 'string',
|
|
608
|
+
description: 'Comma-separated list of MCP server names that gemini-cli is allowed to call (passes --allowed-mcp-server-names to gemini-cli). Only used when --tool gemini.',
|
|
609
|
+
},
|
|
591
610
|
};
|
|
592
611
|
|
|
593
612
|
function hasRawOption(rawArgs, optionName) {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { parseRepositoryUrl, checkLabelPermissions, ensureReadyLabel } from './github-merge.lib.mjs';
|
|
22
22
|
import { createMergeQueueProcessor, MergeStatus, MERGE_QUEUE_CONFIG } from './telegram-merge-queue.lib.mjs';
|
|
23
|
+
import { executeStartScreen } from './telegram-command-execution.lib.mjs';
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Active merge operations map (repoKey -> { processor, chatId, messageId })
|
|
@@ -90,6 +91,77 @@ function parseCommandArgs(text) {
|
|
|
90
91
|
return args;
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Issue #1805: Parse boolean flags out of the tokenised `/merge` args.
|
|
96
|
+
* Supports `--flag`, `--flag=true`, `--flag=false`, `--no-flag` and the
|
|
97
|
+
* trailing positional repository URL. We keep the original positional order
|
|
98
|
+
* so callers can still treat `positionals[0]` as the repo URL.
|
|
99
|
+
*
|
|
100
|
+
* @param {string[]} args - The output of `parseCommandArgs(text)`.
|
|
101
|
+
* @returns {{ positionals: string[], flags: Record<string, boolean> }}
|
|
102
|
+
*/
|
|
103
|
+
export function parseMergeArgs(args) {
|
|
104
|
+
const flags = {};
|
|
105
|
+
const positionals = [];
|
|
106
|
+
for (const arg of args) {
|
|
107
|
+
if (typeof arg !== 'string') continue;
|
|
108
|
+
if (arg.startsWith('--')) {
|
|
109
|
+
const body = arg.slice(2);
|
|
110
|
+
if (!body) continue;
|
|
111
|
+
// --no-foo => foo=false
|
|
112
|
+
if (body.startsWith('no-')) {
|
|
113
|
+
const key = body.slice(3);
|
|
114
|
+
if (key) flags[key] = false;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const eqIdx = body.indexOf('=');
|
|
118
|
+
if (eqIdx === -1) {
|
|
119
|
+
flags[body] = true;
|
|
120
|
+
} else {
|
|
121
|
+
const key = body.slice(0, eqIdx);
|
|
122
|
+
const value = body.slice(eqIdx + 1).toLowerCase();
|
|
123
|
+
flags[key] = !(value === 'false' || value === '0' || value === 'no' || value === 'off');
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
positionals.push(arg);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { positionals, flags };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Issue #1805: Spawner used by the merge queue's auto-resolve pass. For each
|
|
134
|
+
* skipped PR we dispatch a `solve <pr-url> --auto-merge` session through
|
|
135
|
+
* the same `start-screen` runtime the bot uses everywhere else. Keeping this
|
|
136
|
+
* in one place means the per-PR sessions behave exactly like any other
|
|
137
|
+
* `/solve` invocation (same logs, same /watch, same isolation backend).
|
|
138
|
+
*
|
|
139
|
+
* @param {Object} target - Info for the conflicted PR.
|
|
140
|
+
* @param {string} target.url - PR HTML URL passed to `solve`.
|
|
141
|
+
* @param {boolean} verbose - Forwarded to the underlying spawn.
|
|
142
|
+
* @returns {Promise<{ success: boolean, sessionName: string|null, error: string|null, warning: string|null }>}
|
|
143
|
+
*/
|
|
144
|
+
async function spawnAutoResolveSolve(target, verbose) {
|
|
145
|
+
if (!target || !target.url) {
|
|
146
|
+
return { success: false, sessionName: null, error: 'missing PR URL', warning: null };
|
|
147
|
+
}
|
|
148
|
+
const args = [target.url, '--auto-merge'];
|
|
149
|
+
try {
|
|
150
|
+
const result = await executeStartScreen('solve', args, { verbose });
|
|
151
|
+
if (result.warning) {
|
|
152
|
+
return { success: false, sessionName: null, error: null, warning: result.warning };
|
|
153
|
+
}
|
|
154
|
+
if (!result.success) {
|
|
155
|
+
return { success: false, sessionName: null, error: result.error || 'spawn failed', warning: null };
|
|
156
|
+
}
|
|
157
|
+
const match = result.output && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
|
|
158
|
+
const sessionName = match ? match[1] : null;
|
|
159
|
+
return { success: true, sessionName, error: null, warning: null };
|
|
160
|
+
} catch (error) {
|
|
161
|
+
return { success: false, sessionName: null, error: error.message || String(error), warning: null };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
93
165
|
/**
|
|
94
166
|
* Format user-friendly error message
|
|
95
167
|
* Hides debug info unless verbose mode is enabled
|
|
@@ -175,13 +247,17 @@ export function registerMergeCommand(bot, options) {
|
|
|
175
247
|
|
|
176
248
|
// Parse arguments
|
|
177
249
|
const args = parseCommandArgs(ctx.message.text);
|
|
250
|
+
// Issue #1805: split positional args from `--auto-resolve` style flags so
|
|
251
|
+
// the repository URL parsing still sees only the URL token.
|
|
252
|
+
const { positionals, flags } = parseMergeArgs(args);
|
|
253
|
+
const autoResolve = flags['auto-resolve'] === true;
|
|
178
254
|
|
|
179
|
-
if (
|
|
180
|
-
return await ctx.reply("Missing repository URL\\.\n\nUsage: `/merge <repository-url
|
|
255
|
+
if (positionals.length === 0) {
|
|
256
|
+
return await ctx.reply("Missing repository URL\\.\n\nUsage: `/merge <repository-url> [--auto-resolve]`\n\nExample: `/merge https://github.com/owner/repo`\n\nThis will merge all PRs with the 'ready' label, one by one, waiting for CI/CD between each merge\\.\n\nWith `--auto-resolve` the bot also dispatches `/solve <pr> --auto-merge` for every PR that was skipped because of merge conflicts\\.", { parse_mode: 'MarkdownV2', reply_to_message_id: ctx.message.message_id });
|
|
181
257
|
}
|
|
182
258
|
|
|
183
259
|
// Parse and validate repository URL
|
|
184
|
-
const repoUrl =
|
|
260
|
+
const repoUrl = positionals[0];
|
|
185
261
|
const parsedUrl = parseRepositoryUrl(repoUrl);
|
|
186
262
|
|
|
187
263
|
if (!parsedUrl.valid) {
|
|
@@ -234,6 +310,11 @@ export function registerMergeCommand(bot, options) {
|
|
|
234
310
|
// Create the merge queue processor
|
|
235
311
|
const processor = await createMergeQueueProcessor(owner, repo, {
|
|
236
312
|
verbose: VERBOSE,
|
|
313
|
+
// Issue #1805: forward the --auto-resolve flag and inject the spawner.
|
|
314
|
+
// The processor only sees the callback, so unit tests can stub it
|
|
315
|
+
// without spawning real screen sessions.
|
|
316
|
+
autoResolve,
|
|
317
|
+
spawnSolveSession: autoResolve ? target => spawnAutoResolveSolve(target, VERBOSE) : null,
|
|
237
318
|
onProgress: async () => {
|
|
238
319
|
// Update message with progress and cancel button
|
|
239
320
|
try {
|
|
@@ -44,8 +44,24 @@ export const MergeItemStatus = {
|
|
|
44
44
|
MERGED: 'merged',
|
|
45
45
|
FAILED: 'failed',
|
|
46
46
|
SKIPPED: 'skipped',
|
|
47
|
+
// Issue #1805: states reached during the post-queue `--auto-resolve` pass.
|
|
48
|
+
// RESOLVING is set while a `/solve <pr> --auto-merge` session is being
|
|
49
|
+
// spawned for a previously-skipped PR; RESOLVE_FAILED records that the
|
|
50
|
+
// spawn (or the resolution itself) didn't succeed.
|
|
51
|
+
RESOLVING: 'resolving',
|
|
52
|
+
RESOLVE_FAILED: 'resolve_failed',
|
|
47
53
|
};
|
|
48
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Marker that identifies SKIPPED items that the auto-resolve pass should
|
|
57
|
+
* pick up. The same string is returned by `checkPRMergeable()` for
|
|
58
|
+
* `mergeStateStatus === 'DIRTY'` (see github-merge.lib.mjs), so matching
|
|
59
|
+
* on it keeps the two modules in sync without sharing extra state.
|
|
60
|
+
*
|
|
61
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1805
|
|
62
|
+
*/
|
|
63
|
+
export const MERGE_CONFLICT_SKIP_REASON = 'PR has merge conflicts';
|
|
64
|
+
|
|
49
65
|
/**
|
|
50
66
|
* Configuration for merge queue operations
|
|
51
67
|
* Values are loaded from config.lib.mjs which supports environment variable overrides.
|
|
@@ -134,6 +150,11 @@ class MergeQueueItem {
|
|
|
134
150
|
return '❌';
|
|
135
151
|
case MergeItemStatus.SKIPPED:
|
|
136
152
|
return '⏭️';
|
|
153
|
+
// Issue #1805: auto-resolve pass states.
|
|
154
|
+
case MergeItemStatus.RESOLVING:
|
|
155
|
+
return '🛠️';
|
|
156
|
+
case MergeItemStatus.RESOLVE_FAILED:
|
|
157
|
+
return '⚠️';
|
|
137
158
|
default:
|
|
138
159
|
return '❓';
|
|
139
160
|
}
|
|
@@ -152,6 +173,12 @@ export class MergeQueueProcessor {
|
|
|
152
173
|
this.onProgress = options.onProgress || null;
|
|
153
174
|
this.onComplete = options.onComplete || null;
|
|
154
175
|
this.onError = options.onError || null;
|
|
176
|
+
// Issue #1805: when true the queue runs a second pass after the normal
|
|
177
|
+
// merge loop, spawning `/solve <pr> --auto-merge` for every PR that was
|
|
178
|
+
// SKIPPED due to merge conflicts. The actual spawner is injected so
|
|
179
|
+
// tests can run without touching the screen runtime.
|
|
180
|
+
this.autoResolve = options.autoResolve === true;
|
|
181
|
+
this.spawnSolveSession = typeof options.spawnSolveSession === 'function' ? options.spawnSolveSession : null;
|
|
155
182
|
|
|
156
183
|
// State
|
|
157
184
|
this.items = [];
|
|
@@ -161,6 +188,9 @@ export class MergeQueueProcessor {
|
|
|
161
188
|
this.startedAt = null;
|
|
162
189
|
this.completedAt = null;
|
|
163
190
|
this.error = null;
|
|
191
|
+
// Issue #1805: track auto-resolve progress so the renderer can surface it.
|
|
192
|
+
this.autoResolveActive = false;
|
|
193
|
+
this.autoResolveCurrent = null;
|
|
164
194
|
|
|
165
195
|
// Statistics
|
|
166
196
|
this.stats = {
|
|
@@ -168,6 +198,11 @@ export class MergeQueueProcessor {
|
|
|
168
198
|
merged: 0,
|
|
169
199
|
failed: 0,
|
|
170
200
|
skipped: 0,
|
|
201
|
+
// Issue #1805: number of skipped conflict PRs the auto-resolve pass
|
|
202
|
+
// successfully handed off to `solve`, and the number that failed to
|
|
203
|
+
// be handed off (e.g. screen runner missing).
|
|
204
|
+
autoResolved: 0,
|
|
205
|
+
autoResolveFailed: 0,
|
|
171
206
|
};
|
|
172
207
|
}
|
|
173
208
|
|
|
@@ -329,6 +364,15 @@ export class MergeQueueProcessor {
|
|
|
329
364
|
}
|
|
330
365
|
}
|
|
331
366
|
|
|
367
|
+
// Issue #1805: After the normal queue settles, optionally hand off
|
|
368
|
+
// every PR that was SKIPPED with a merge-conflict reason to the
|
|
369
|
+
// `/solve <pr> --auto-merge` flow. This lets a single `/merge`
|
|
370
|
+
// invocation both merge the easy PRs and dispatch conflict-resolution
|
|
371
|
+
// sessions for the rest.
|
|
372
|
+
if (this.autoResolve && !this.isCancelled) {
|
|
373
|
+
await this.runAutoResolve();
|
|
374
|
+
}
|
|
375
|
+
|
|
332
376
|
this.completedAt = new Date();
|
|
333
377
|
this.status = this.isCancelled ? MergeStatus.CANCELLED : MergeStatus.COMPLETED;
|
|
334
378
|
|
|
@@ -483,6 +527,100 @@ export class MergeQueueProcessor {
|
|
|
483
527
|
this.log('Cancellation requested');
|
|
484
528
|
}
|
|
485
529
|
|
|
530
|
+
/**
|
|
531
|
+
* Issue #1805: Return queue items that were skipped because of merge
|
|
532
|
+
* conflicts. These are the candidates the auto-resolve pass hands off
|
|
533
|
+
* to `/solve <pr> --auto-merge`.
|
|
534
|
+
*
|
|
535
|
+
* @returns {MergeQueueItem[]}
|
|
536
|
+
*/
|
|
537
|
+
getConflictedItems() {
|
|
538
|
+
return this.items.filter(item => item.status === MergeItemStatus.SKIPPED && item.error === MERGE_CONFLICT_SKIP_REASON);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Issue #1805: Iterate every conflict-skipped item and hand it off to a
|
|
543
|
+
* `/solve <pr-url> --auto-merge` session via the injected
|
|
544
|
+
* `spawnSolveSession` callback. Each spawn is awaited so the bot doesn't
|
|
545
|
+
* fan out unbounded Claude sessions. The PR's status is updated as the
|
|
546
|
+
* spawn succeeds or fails.
|
|
547
|
+
*
|
|
548
|
+
* @returns {Promise<void>}
|
|
549
|
+
*/
|
|
550
|
+
async runAutoResolve() {
|
|
551
|
+
const conflicted = this.getConflictedItems();
|
|
552
|
+
if (conflicted.length === 0) {
|
|
553
|
+
this.log('Auto-resolve: no merge-conflict skips to process');
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!this.spawnSolveSession) {
|
|
558
|
+
// Guard against misconfiguration — the queue can't resolve without a
|
|
559
|
+
// spawner. Surface this to the user via the same channel as other
|
|
560
|
+
// queue feedback rather than throwing.
|
|
561
|
+
this.log(`Auto-resolve: ${conflicted.length} conflict PR(s) but no spawnSolveSession callback provided`);
|
|
562
|
+
for (const item of conflicted) {
|
|
563
|
+
item.status = MergeItemStatus.RESOLVE_FAILED;
|
|
564
|
+
item.autoResolveError = 'auto-resolve is not configured';
|
|
565
|
+
this.stats.autoResolveFailed++;
|
|
566
|
+
}
|
|
567
|
+
if (this.onProgress) {
|
|
568
|
+
await this.onProgress(this.getProgressUpdate());
|
|
569
|
+
}
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
this.autoResolveActive = true;
|
|
574
|
+
this.log(`Auto-resolve: dispatching ${conflicted.length} conflict PR(s) to /solve --auto-merge`);
|
|
575
|
+
try {
|
|
576
|
+
for (const item of conflicted) {
|
|
577
|
+
if (this.isCancelled) {
|
|
578
|
+
this.log('Auto-resolve: cancelled mid-pass');
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
item.status = MergeItemStatus.RESOLVING;
|
|
583
|
+
this.autoResolveCurrent = item.pr.number;
|
|
584
|
+
if (this.onProgress) {
|
|
585
|
+
await this.onProgress(this.getProgressUpdate());
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
const result = await this.spawnSolveSession({
|
|
590
|
+
url: item.pr.url,
|
|
591
|
+
owner: this.owner,
|
|
592
|
+
repo: this.repo,
|
|
593
|
+
prNumber: item.pr.number,
|
|
594
|
+
title: item.pr.title,
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
if (result && result.success) {
|
|
598
|
+
item.autoResolveSession = result.sessionName || result.session || null;
|
|
599
|
+
this.stats.autoResolved++;
|
|
600
|
+
this.log(`Auto-resolve: spawned solve session for PR #${item.pr.number}${item.autoResolveSession ? ` (session ${item.autoResolveSession})` : ''}`);
|
|
601
|
+
} else {
|
|
602
|
+
item.status = MergeItemStatus.RESOLVE_FAILED;
|
|
603
|
+
item.autoResolveError = (result && (result.error || result.warning)) || 'spawn failed';
|
|
604
|
+
this.stats.autoResolveFailed++;
|
|
605
|
+
this.log(`Auto-resolve: failed to spawn solve session for PR #${item.pr.number}: ${item.autoResolveError}`);
|
|
606
|
+
}
|
|
607
|
+
} catch (error) {
|
|
608
|
+
item.status = MergeItemStatus.RESOLVE_FAILED;
|
|
609
|
+
item.autoResolveError = error.message || String(error);
|
|
610
|
+
this.stats.autoResolveFailed++;
|
|
611
|
+
console.error(`[ERROR] /merge-queue: auto-resolve error for PR #${item.pr.number}: ${item.autoResolveError}`);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (this.onProgress) {
|
|
615
|
+
await this.onProgress(this.getProgressUpdate());
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
} finally {
|
|
619
|
+
this.autoResolveActive = false;
|
|
620
|
+
this.autoResolveCurrent = null;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
486
624
|
/**
|
|
487
625
|
* Wait for any active CI runs on the target branch to complete
|
|
488
626
|
* Issue #1307: Prevents merging while post-merge CI from previous merges is still running
|
|
@@ -658,6 +796,13 @@ export class MergeQueueProcessor {
|
|
|
658
796
|
status: this.status,
|
|
659
797
|
current: currentItem ? currentItem.getDescription() : null,
|
|
660
798
|
currentStatus: currentItem ? currentItem.status : null,
|
|
799
|
+
// Issue #1805: surface auto-resolve progress so renderers/tests can
|
|
800
|
+
// show what's happening during the post-queue pass.
|
|
801
|
+
autoResolve: {
|
|
802
|
+
enabled: this.autoResolve,
|
|
803
|
+
active: this.autoResolveActive,
|
|
804
|
+
currentPrNumber: this.autoResolveCurrent,
|
|
805
|
+
},
|
|
661
806
|
progress: {
|
|
662
807
|
processed,
|
|
663
808
|
total: this.stats.total,
|
|
@@ -666,10 +811,17 @@ export class MergeQueueProcessor {
|
|
|
666
811
|
stats: { ...this.stats },
|
|
667
812
|
items: this.items.map(item => ({
|
|
668
813
|
prNumber: item.pr.number,
|
|
814
|
+
// Issue #1805: expose PR/issue URLs so renderers can produce
|
|
815
|
+
// clickable links instead of plain `\#NNN` text.
|
|
816
|
+
prUrl: item.pr.url || null,
|
|
817
|
+
issueNumber: item.issue ? item.issue.number : null,
|
|
818
|
+
issueUrl: item.issue ? item.issue.url || null : null,
|
|
669
819
|
title: item.pr.title,
|
|
670
820
|
status: item.status,
|
|
671
821
|
error: item.error,
|
|
672
822
|
emoji: item.getStatusEmoji(),
|
|
823
|
+
autoResolveSession: item.autoResolveSession || null,
|
|
824
|
+
autoResolveError: item.autoResolveError || null,
|
|
673
825
|
})),
|
|
674
826
|
};
|
|
675
827
|
}
|
|
@@ -684,13 +836,24 @@ export class MergeQueueProcessor {
|
|
|
684
836
|
status: this.status,
|
|
685
837
|
duration: `${Math.floor(duration / 60)}m ${duration % 60}s`,
|
|
686
838
|
stats: { ...this.stats },
|
|
839
|
+
autoResolve: {
|
|
840
|
+
enabled: this.autoResolve,
|
|
841
|
+
resolved: this.stats.autoResolved,
|
|
842
|
+
failed: this.stats.autoResolveFailed,
|
|
843
|
+
},
|
|
687
844
|
items: this.items.map(item => ({
|
|
688
845
|
prNumber: item.pr.number,
|
|
846
|
+
// Issue #1805: expose PR/issue URLs so renderers can produce
|
|
847
|
+
// clickable links instead of plain `\#NNN` text.
|
|
848
|
+
prUrl: item.pr.url || null,
|
|
689
849
|
title: item.pr.title,
|
|
690
850
|
issueNumber: item.issue ? item.issue.number : null,
|
|
851
|
+
issueUrl: item.issue ? item.issue.url || null : null,
|
|
691
852
|
status: item.status,
|
|
692
853
|
error: item.error,
|
|
693
854
|
emoji: item.getStatusEmoji(),
|
|
855
|
+
autoResolveSession: item.autoResolveSession || null,
|
|
856
|
+
autoResolveError: item.autoResolveError || null,
|
|
694
857
|
})),
|
|
695
858
|
};
|
|
696
859
|
}
|
|
@@ -748,11 +911,25 @@ export class MergeQueueProcessor {
|
|
|
748
911
|
message += `⏱️ Waiting for post\\-merge CI \\(PR \\#${this.currentPostMergePR}\\)\\.\\.\\.\n\n`;
|
|
749
912
|
}
|
|
750
913
|
|
|
751
|
-
//
|
|
752
|
-
|
|
914
|
+
// Issue #1805: surface the auto-resolve pass when it is currently
|
|
915
|
+
// active. This appears in place of "current item" because by then the
|
|
916
|
+
// main queue loop has finished.
|
|
917
|
+
if (update.autoResolve && update.autoResolve.active && update.autoResolve.currentPrNumber) {
|
|
918
|
+
const activeItem = update.items.find(it => it.prNumber === update.autoResolve.currentPrNumber);
|
|
919
|
+
const link = activeItem ? this.formatPrLink(activeItem.prNumber, activeItem.title, activeItem.prUrl) : `\\#${update.autoResolve.currentPrNumber}`;
|
|
920
|
+
message += `🛠️ Auto\\-resolving ${link}\n\n`;
|
|
921
|
+
} else if (update.current && !this.waitingForTargetBranchCI && !this.waitingForPostMergeCI) {
|
|
922
|
+
// Current item being processed
|
|
753
923
|
const statusEmoji = update.currentStatus === MergeItemStatus.WAITING_CI ? '⏱️' : '🔄';
|
|
754
|
-
|
|
755
|
-
|
|
924
|
+
const currentItem = this.items[this.currentIndex];
|
|
925
|
+
if (currentItem) {
|
|
926
|
+
const link = this.formatPrLink(currentItem.pr.number, currentItem.pr.title, currentItem.pr.url);
|
|
927
|
+
const issueSuffix = this.formatIssueRef(currentItem.issue ? currentItem.issue.number : null, currentItem.issue ? currentItem.issue.url : null);
|
|
928
|
+
message += `${statusEmoji} ${link}${issueSuffix}\n\n`;
|
|
929
|
+
} else {
|
|
930
|
+
// Fallback: escape the description if we somehow don't have an item handle
|
|
931
|
+
message += `${statusEmoji} ${this.escapeMarkdown(update.current)}\n\n`;
|
|
932
|
+
}
|
|
756
933
|
}
|
|
757
934
|
|
|
758
935
|
// Show errors/failures/skips inline so user gets immediate feedback (Issue #1269, #1294)
|
|
@@ -762,8 +939,9 @@ export class MergeQueueProcessor {
|
|
|
762
939
|
message += `⚠️ *Issues:*\n`;
|
|
763
940
|
for (const item of problemItems.slice(0, 5)) {
|
|
764
941
|
const statusEmoji = item.status === MergeItemStatus.FAILED ? '❌' : '⏭️';
|
|
765
|
-
// Issue #
|
|
766
|
-
|
|
942
|
+
// Issue #1805: emit the PR reference as a clickable link instead of plain text.
|
|
943
|
+
const prRef = this.formatPrLink(item.prNumber, '', item.prUrl);
|
|
944
|
+
message += ` ${statusEmoji} ${prRef}: ${this.escapeMarkdown(item.error.substring(0, 50))}${item.error.length > 50 ? '\\.\\.\\.' : ''}\n`;
|
|
767
945
|
}
|
|
768
946
|
if (problemItems.length > 5) {
|
|
769
947
|
// Issue #1339: escape the ellipsis '...' for MarkdownV2
|
|
@@ -772,11 +950,10 @@ export class MergeQueueProcessor {
|
|
|
772
950
|
message += '\n';
|
|
773
951
|
}
|
|
774
952
|
|
|
775
|
-
// PRs list with emojis
|
|
953
|
+
// PRs list with emojis (Issue #1805: render as clickable MarkdownV2 links)
|
|
776
954
|
message += `*Queue:*\n`;
|
|
777
955
|
for (const item of update.items.slice(0, 10)) {
|
|
778
|
-
|
|
779
|
-
message += `${item.emoji} \\#${item.prNumber}: ${this.escapeMarkdown(item.title.substring(0, 35))}${item.title.length > 35 ? '\\.\\.\\.' : ''}\n`;
|
|
956
|
+
message += `${item.emoji} ${this.formatPrLink(item.prNumber, item.title, item.prUrl)}\n`;
|
|
780
957
|
}
|
|
781
958
|
|
|
782
959
|
if (update.items.length > 10) {
|
|
@@ -830,7 +1007,20 @@ export class MergeQueueProcessor {
|
|
|
830
1007
|
message += `✅ Merged: ${report.stats.merged} `;
|
|
831
1008
|
message += `❌ Failed: ${report.stats.failed} `;
|
|
832
1009
|
message += `⏭️ Skipped: ${report.stats.skipped} `;
|
|
833
|
-
message += `📋 Total: ${report.stats.total}\n
|
|
1010
|
+
message += `📋 Total: ${report.stats.total}\n`;
|
|
1011
|
+
|
|
1012
|
+
// Issue #1805: surface the auto-resolve pass summary when it ran. We
|
|
1013
|
+
// always show the line when the flag was set so users see "0 dispatched"
|
|
1014
|
+
// when there was nothing to do.
|
|
1015
|
+
if (report.autoResolve && report.autoResolve.enabled) {
|
|
1016
|
+
message += `🛠️ Auto\\-resolve dispatched: ${report.autoResolve.resolved}`;
|
|
1017
|
+
if (report.autoResolve.failed > 0) {
|
|
1018
|
+
message += ` ⚠️ Auto\\-resolve failed: ${report.autoResolve.failed}`;
|
|
1019
|
+
}
|
|
1020
|
+
message += '\n';
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
message += '\n';
|
|
834
1024
|
|
|
835
1025
|
// Issue #1341: Show branch CI health failure details if applicable
|
|
836
1026
|
if (this.branchCIFailedRuns && this.branchCIFailedRuns.length > 0) {
|
|
@@ -862,19 +1052,25 @@ export class MergeQueueProcessor {
|
|
|
862
1052
|
message += '\n';
|
|
863
1053
|
}
|
|
864
1054
|
|
|
865
|
-
// Details
|
|
1055
|
+
// Details (Issue #1805: render PR and issue references as clickable
|
|
1056
|
+
// MarkdownV2 links so the user can jump directly to the PR or issue).
|
|
866
1057
|
if (report.items.length > 0) {
|
|
867
1058
|
message += `*Results:*\n`;
|
|
868
1059
|
for (const item of report.items) {
|
|
869
|
-
const
|
|
1060
|
+
const prLink = this.formatPrLink(item.prNumber, item.title, item.prUrl);
|
|
1061
|
+
const issueRef = this.formatIssueRef(item.issueNumber, item.issueUrl);
|
|
870
1062
|
// Issue #1294: Show skip/fail reason so users understand what action is required
|
|
871
1063
|
let reasonText = '';
|
|
872
|
-
|
|
1064
|
+
const isAutoResolveState = item.status === MergeItemStatus.RESOLVING || item.status === MergeItemStatus.RESOLVE_FAILED;
|
|
1065
|
+
if (item.autoResolveError && isAutoResolveState) {
|
|
1066
|
+
const truncated = item.autoResolveError.length > 50 ? item.autoResolveError.substring(0, 47) + '...' : item.autoResolveError;
|
|
1067
|
+
reasonText = ` \\(${this.escapeMarkdown(truncated)}\\)`;
|
|
1068
|
+
} else if (item.error && (item.status === MergeItemStatus.SKIPPED || item.status === MergeItemStatus.FAILED)) {
|
|
873
1069
|
// Truncate long reasons and escape for MarkdownV2
|
|
874
1070
|
const truncatedReason = item.error.length > 50 ? item.error.substring(0, 47) + '...' : item.error;
|
|
875
1071
|
reasonText = `: ${this.escapeMarkdown(truncatedReason)}`;
|
|
876
1072
|
}
|
|
877
|
-
message += `${item.emoji}
|
|
1073
|
+
message += `${item.emoji} ${prLink}${issueRef}${reasonText}\n`;
|
|
878
1074
|
}
|
|
879
1075
|
}
|
|
880
1076
|
|
|
@@ -888,6 +1084,43 @@ export class MergeQueueProcessor {
|
|
|
888
1084
|
return String(text).replace(/[_*[\]()~`>#+\-=|{}.!]/g, '\\$&');
|
|
889
1085
|
}
|
|
890
1086
|
|
|
1087
|
+
/**
|
|
1088
|
+
* Issue #1805: Escape `)` and `\` inside a URL for a MarkdownV2 inline link.
|
|
1089
|
+
* URLs must NOT be passed through `escapeMarkdown()` because that would also
|
|
1090
|
+
* mangle characters that are valid inside URLs (`.`, `-`, `_`, etc.).
|
|
1091
|
+
*/
|
|
1092
|
+
escapeMarkdownLinkUrl(url) {
|
|
1093
|
+
return String(url).replace(/[\\)]/g, '\\$&');
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Issue #1805: Build a clickable MarkdownV2 link for a PR's `\#N: title`
|
|
1098
|
+
* reference. Falls back to plain escaped text when no URL is available so
|
|
1099
|
+
* the message still renders correctly on legacy items.
|
|
1100
|
+
*/
|
|
1101
|
+
formatPrLink(prNumber, title, url, options = {}) {
|
|
1102
|
+
const maxTitle = typeof options.maxTitle === 'number' ? options.maxTitle : 35;
|
|
1103
|
+
const trimmedTitle = title || '';
|
|
1104
|
+
const truncated = trimmedTitle.length > maxTitle ? trimmedTitle.substring(0, maxTitle) : trimmedTitle;
|
|
1105
|
+
const ellipsis = trimmedTitle.length > maxTitle ? '\\.\\.\\.' : '';
|
|
1106
|
+
const titlePart = trimmedTitle ? `: ${this.escapeMarkdown(truncated)}${ellipsis}` : '';
|
|
1107
|
+
const label = `\\#${prNumber}${titlePart}`;
|
|
1108
|
+
if (!url) return label;
|
|
1109
|
+
return `[${label}](${this.escapeMarkdownLinkUrl(url)})`;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Issue #1805: Build the ` (Issue #N)` suffix as a clickable link. The
|
|
1114
|
+
* outer parentheses are literal MarkdownV2 (escaped), so the inner inline
|
|
1115
|
+
* link is not nested inside another entity.
|
|
1116
|
+
*/
|
|
1117
|
+
formatIssueRef(issueNumber, url) {
|
|
1118
|
+
if (!issueNumber) return '';
|
|
1119
|
+
const label = `Issue \\#${issueNumber}`;
|
|
1120
|
+
if (!url) return ` \\(${label}\\)`;
|
|
1121
|
+
return ` \\([${label}](${this.escapeMarkdownLinkUrl(url)})\\)`;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
891
1124
|
/**
|
|
892
1125
|
* Sleep helper
|
|
893
1126
|
*/
|