@link-assistant/hive-mind 1.73.0 → 1.73.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 +12 -0
- package/package.json +1 -1
- package/src/solve.auto-merge-helpers.lib.mjs +116 -1
- package/src/solve.auto-merge.lib.mjs +31 -3
- package/src/solve.auto-pr-placeholder.lib.mjs +314 -21
- package/src/solve.auto-pr.lib.mjs +26 -67
- package/src/solve.config.lib.mjs +10 -0
- package/src/solve.feedback.lib.mjs +19 -0
- package/src/solve.watch.lib.mjs +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.73.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0af65ad: Handle the auto-PR placeholder being listed in the target repository's `.gitignore` without aborting the whole run (issue #1825). Previously `git add .gitkeep` exited non-zero and the solver threw `Failed to add .gitkeep` → `FATAL ERROR: PR creation failed`. Now, when the placeholder (`.gitkeep` or `CLAUDE.md`) is gitignored, the solver by default prints a clear, environment-agnostic root-cause explanation and stops cleanly instead of forcing the commit. Two opt-in flags are added (usable with both `solve` and `/solve`): `--remove-git-keep-from-git-ignore` removes the literal placeholder entry from `.gitignore` first and then commits normally, and `--force-git-keep-commit` commits the placeholder anyway with `git add -f`.
|
|
8
|
+
|
|
9
|
+
## 1.73.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- df8b776: Stop the auto-restart-until-mergeable and watch loops from treating the AI agent's own session comments (e.g. free-form "CI now green" status updates posted through the authenticated account) as new human feedback, which caused an endless restart loop until the iteration limit (issue #1827). The check window is now advanced monotonically, every comment the authenticated account posts during a session is tracked by ID, and watch-mode feedback counting excludes tool-generated comments by marker and tracked ID.
|
|
14
|
+
|
|
3
15
|
## 1.73.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -76,7 +76,7 @@ const formatRunLine = run => {
|
|
|
76
76
|
// search scope for checkForExistingComment() stays in lock-step with the
|
|
77
77
|
// markers actually embedded in tool-posted comments.
|
|
78
78
|
const toolComments = await import('./tool-comments.lib.mjs');
|
|
79
|
-
const { SESSION_ENDING_MARKERS, isToolGeneratedComment, isToolTrackedCommentId } = toolComments;
|
|
79
|
+
const { SESSION_ENDING_MARKERS, isToolGeneratedComment, isToolTrackedCommentId, trackToolCommentId } = toolComments;
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
82
|
* Issue #1323: Check if a comment with specific content already exists on the PR
|
|
@@ -292,6 +292,121 @@ export const checkForNonBotComments = async (owner, repo, prNumber, issueNumber,
|
|
|
292
292
|
}
|
|
293
293
|
};
|
|
294
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Issue #1827: Compute the next monotonic check-window cutoff for the
|
|
297
|
+
* auto-restart-until-mergeable loop. The cutoff must never move backwards:
|
|
298
|
+
* after an AI session, lastCheckTime is set to a moment *after* the agent's own
|
|
299
|
+
* comments, so rewinding it to the iteration's start time (captured before the
|
|
300
|
+
* AI ran) would re-detect those comments as new feedback — the root cause of
|
|
301
|
+
* the restart loop in #1827. Returns whichever timestamp is later.
|
|
302
|
+
*
|
|
303
|
+
* @param {Date} lastCheckTime - current cutoff
|
|
304
|
+
* @param {Date} candidate - proposed new cutoff (usually the iteration start time)
|
|
305
|
+
* @returns {Date} the later of the two timestamps
|
|
306
|
+
*/
|
|
307
|
+
export const nextMonotonicCheckTime = (lastCheckTime, candidate) => {
|
|
308
|
+
if (!(lastCheckTime instanceof Date)) return candidate;
|
|
309
|
+
if (!(candidate instanceof Date)) return lastCheckTime;
|
|
310
|
+
return candidate.getTime() > lastCheckTime.getTime() ? candidate : lastCheckTime;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Issue #1827: Register every comment authored by the authenticated GitHub
|
|
315
|
+
* account during an AI working session as a tool-generated comment.
|
|
316
|
+
*
|
|
317
|
+
* During a session, the AI agent can post free-form status comments through the
|
|
318
|
+
* authenticated account (e.g. "✅ CI now green", "✅ Verification pass"). These
|
|
319
|
+
* are NOT routed through postTrackedComment(), so their IDs were never captured,
|
|
320
|
+
* and they match none of the tool markers. Once issue #1821 made the watch loop
|
|
321
|
+
* trust same-account comments as human feedback, the very next iteration
|
|
322
|
+
* re-detected these comments as fresh feedback and triggered an endless
|
|
323
|
+
* auto-restart loop until the limit was hit.
|
|
324
|
+
*
|
|
325
|
+
* Because the authenticated account is busy running the AI for the whole
|
|
326
|
+
* session window, any comment it authored within that window is the tool's own,
|
|
327
|
+
* not human feedback. Tracking those IDs makes checkForNonBotComments filter
|
|
328
|
+
* them by ID regardless of timestamps — a defense that also survives clock skew
|
|
329
|
+
* between the local clock and GitHub's `created_at` (which a purely
|
|
330
|
+
* time-based cutoff cannot).
|
|
331
|
+
*
|
|
332
|
+
* @param {string} owner - Repository owner
|
|
333
|
+
* @param {string} repo - Repository name
|
|
334
|
+
* @param {number} prNumber - Pull request number
|
|
335
|
+
* @param {number} issueNumber - Issue number (may equal prNumber)
|
|
336
|
+
* @param {Date|string|number} sinceTime - Start of the session window
|
|
337
|
+
* @param {Function} commandRunner - Tagged-template command runner, injectable for tests
|
|
338
|
+
* @param {Object} options
|
|
339
|
+
* @param {boolean} [options.verbose=false]
|
|
340
|
+
* @param {string} [options.currentUser] - Pre-resolved authenticated login (skips the `gh api user` call)
|
|
341
|
+
* @returns {Promise<string[]>} Newly tracked comment IDs (as strings)
|
|
342
|
+
*/
|
|
343
|
+
export const trackAuthenticatedUserCommentsSince = async (owner, repo, prNumber, issueNumber, sinceTime, commandRunner = $, options = {}) => {
|
|
344
|
+
const { verbose = false, currentUser: providedUser } = options;
|
|
345
|
+
const trackedIds = [];
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
let currentUser = providedUser || null;
|
|
349
|
+
if (!currentUser) {
|
|
350
|
+
try {
|
|
351
|
+
const userResult = await commandRunner`gh api user --jq .login`;
|
|
352
|
+
if (userResult.code === 0) {
|
|
353
|
+
currentUser = userResult.stdout.toString().trim();
|
|
354
|
+
}
|
|
355
|
+
} catch {
|
|
356
|
+
// Without the authenticated login we cannot attribute comments; bail out.
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (!currentUser) return trackedIds;
|
|
360
|
+
|
|
361
|
+
const since = sinceTime instanceof Date ? sinceTime : new Date(sinceTime);
|
|
362
|
+
|
|
363
|
+
const fetchComments = async path => {
|
|
364
|
+
try {
|
|
365
|
+
const result = await commandRunner`gh api ${path} --paginate`;
|
|
366
|
+
if (result.code === 0 && result.stdout) {
|
|
367
|
+
return JSON.parse(result.stdout.toString() || '[]');
|
|
368
|
+
}
|
|
369
|
+
} catch {
|
|
370
|
+
// Ignore fetch/parse failures for an individual endpoint.
|
|
371
|
+
}
|
|
372
|
+
return [];
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const prComments = await fetchComments(`repos/${owner}/${repo}/issues/${prNumber}/comments`);
|
|
376
|
+
const prReviewComments = await fetchComments(`repos/${owner}/${repo}/pulls/${prNumber}/comments`);
|
|
377
|
+
let issueComments = [];
|
|
378
|
+
if (issueNumber && issueNumber !== prNumber) {
|
|
379
|
+
issueComments = await fetchComments(`repos/${owner}/${repo}/issues/${issueNumber}/comments`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const allComments = [...prComments, ...prReviewComments, ...issueComments];
|
|
383
|
+
for (const comment of allComments) {
|
|
384
|
+
const login = comment.user?.login;
|
|
385
|
+
if (!login || login !== currentUser) continue;
|
|
386
|
+
// Inclusive lower bound: a comment posted at the exact session start is
|
|
387
|
+
// still the tool's own. created_at uses GitHub's clock, so allow equality.
|
|
388
|
+
const createdAt = new Date(comment.created_at);
|
|
389
|
+
if (createdAt < since) continue;
|
|
390
|
+
if (isToolTrackedCommentId(comment.id)) continue;
|
|
391
|
+
trackToolCommentId(comment.id);
|
|
392
|
+
trackedIds.push(String(comment.id));
|
|
393
|
+
if (verbose) {
|
|
394
|
+
console.log(`[VERBOSE] Tracking authenticated-user session comment ${comment.id} from ${login} at ${comment.created_at}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
} catch (error) {
|
|
398
|
+
reportError(error, {
|
|
399
|
+
context: 'track_authenticated_user_comments',
|
|
400
|
+
owner,
|
|
401
|
+
repo,
|
|
402
|
+
prNumber,
|
|
403
|
+
operation: 'track_session_comments',
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return trackedIds;
|
|
408
|
+
};
|
|
409
|
+
|
|
295
410
|
/**
|
|
296
411
|
* Get the reasons why PR is not mergeable
|
|
297
412
|
* Issue #1314: Comprehensive CI/CD status handling covering all possible states:
|
|
@@ -53,7 +53,7 @@ import { limitReset } from './config.lib.mjs';
|
|
|
53
53
|
|
|
54
54
|
// Import helper functions extracted for file size management (Issue #1593)
|
|
55
55
|
const autoMergeHelpers = await import('./solve.auto-merge-helpers.lib.mjs');
|
|
56
|
-
const { checkForExistingComment, checkForNonBotComments, getMergeBlockers } = autoMergeHelpers;
|
|
56
|
+
const { checkForExistingComment, checkForNonBotComments, getMergeBlockers, trackAuthenticatedUserCommentsSince, nextMonotonicCheckTime } = autoMergeHelpers;
|
|
57
57
|
|
|
58
58
|
// Issue #1769: cancelled/stale CI re-run failures need a human action stop, not polling forever.
|
|
59
59
|
const cancelledCiRerunLib = await import('./cancelled-ci-rerun.lib.mjs');
|
|
@@ -1031,6 +1031,26 @@ No further AI sessions will be started automatically for this run. Please review
|
|
|
1031
1031
|
await log(formatAligned('✅', `${argv.tool.toUpperCase()} execution completed:`, 'Checking if PR is now mergeable...'));
|
|
1032
1032
|
}
|
|
1033
1033
|
|
|
1034
|
+
// Issue #1827: Register every comment the authenticated account posted
|
|
1035
|
+
// during this AI session (free-form status comments like "✅ CI now
|
|
1036
|
+
// green" the agent writes itself, which bypass postTrackedComment and
|
|
1037
|
+
// match no tool marker). Tracking their IDs stops the next iteration's
|
|
1038
|
+
// checkForNonBotComments from mistaking them for fresh human feedback.
|
|
1039
|
+
try {
|
|
1040
|
+
const tracked = await trackAuthenticatedUserCommentsSince(owner, repo, prNumber, issueNumber, iterationStartTime, $, { verbose: argv.verbose });
|
|
1041
|
+
if (argv.verbose && tracked.length > 0) {
|
|
1042
|
+
await log(formatAligned('🧷', 'Tracked own session comments:', `${tracked.length} (won't count as new feedback)`, 2));
|
|
1043
|
+
}
|
|
1044
|
+
} catch (trackError) {
|
|
1045
|
+
reportError(trackError, {
|
|
1046
|
+
context: 'track_authenticated_user_session_comments',
|
|
1047
|
+
prNumber,
|
|
1048
|
+
owner,
|
|
1049
|
+
repo,
|
|
1050
|
+
operation: 'track_session_comments',
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1034
1054
|
// Update last check time after restart
|
|
1035
1055
|
lastCheckTime = new Date();
|
|
1036
1056
|
} else if (blockers.length > 0) {
|
|
@@ -1071,8 +1091,16 @@ No further AI sessions will be started automatically for this run. Please review
|
|
|
1071
1091
|
await log(formatAligned('', 'No action needed', 'Continuing to monitor...', 2));
|
|
1072
1092
|
}
|
|
1073
1093
|
|
|
1074
|
-
//
|
|
1075
|
-
lastCheckTime
|
|
1094
|
+
// Issue #1827: Advance the check window monotonically — never move it
|
|
1095
|
+
// backwards. In the restart branch above, lastCheckTime was already set
|
|
1096
|
+
// to a moment *after* the AI session (and after any comments the agent
|
|
1097
|
+
// posted). currentTime was captured at the *start* of this iteration,
|
|
1098
|
+
// before the AI ran, so assigning it unconditionally here would rewind
|
|
1099
|
+
// the window and re-detect the agent's own comments as new feedback
|
|
1100
|
+
// (the root cause of the auto-restart loop in #1827). In the non-restart
|
|
1101
|
+
// branches lastCheckTime is still the previous iteration's value, which
|
|
1102
|
+
// is < currentTime, so this correctly advances it.
|
|
1103
|
+
lastCheckTime = nextMonotonicCheckTime(lastCheckTime, currentTime);
|
|
1076
1104
|
} catch (error) {
|
|
1077
1105
|
reportError(error, {
|
|
1078
1106
|
context: 'watch_until_mergeable',
|
|
@@ -5,6 +5,263 @@
|
|
|
5
5
|
* the max-lines lint budget.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Decide whether a single .gitignore line is a literal entry for `fileName`.
|
|
13
|
+
*
|
|
14
|
+
* We only auto-remove exact placeholder entries (e.g. a line that is just
|
|
15
|
+
* `.gitkeep`, `/.gitkeep`, `.gitkeep/` or `/.gitkeep/`). Glob rules such as
|
|
16
|
+
* `.git*` are intentionally left untouched: removing them could un-ignore
|
|
17
|
+
* unrelated files, which the user did not ask for.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} line - raw .gitignore line.
|
|
20
|
+
* @param {string} fileName - placeholder file name (e.g. `.gitkeep`).
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
function isLiteralIgnoreEntry(line, fileName) {
|
|
24
|
+
const trimmed = line.trim();
|
|
25
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
// Normalize away an optional leading "/" (anchored) and trailing "/" (dir).
|
|
29
|
+
const normalized = trimmed.replace(/^\//, '').replace(/\/$/, '');
|
|
30
|
+
return normalized === fileName;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse a single `git check-ignore -v <file>` output line.
|
|
35
|
+
*
|
|
36
|
+
* Format (when the rule comes from a file):
|
|
37
|
+
* <source>:<linenum>:<pattern>\t<pathname>
|
|
38
|
+
*
|
|
39
|
+
* @param {string} output - raw stdout from `git check-ignore -v`.
|
|
40
|
+
* @returns {{source: string, lineNum: number, pattern: string} | null}
|
|
41
|
+
*/
|
|
42
|
+
function parseCheckIgnoreVerbose(output) {
|
|
43
|
+
const firstLine = (output || '')
|
|
44
|
+
.split('\n')
|
|
45
|
+
.map(l => l.trim())
|
|
46
|
+
.find(Boolean);
|
|
47
|
+
if (!firstLine) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
// Split off the trailing "\t<pathname>" so colons in the pathname can't confuse us.
|
|
51
|
+
const meta = firstLine.split('\t')[0];
|
|
52
|
+
// Greedy source match lets us tolerate paths containing ":" (rare); the
|
|
53
|
+
// line number is the last ":<digits>:" group before the pattern.
|
|
54
|
+
const match = meta.match(/^(.*):(\d+):(.*)$/);
|
|
55
|
+
if (!match) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return { source: match[1], lineNum: Number(match[2]), pattern: match[3] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Remove the literal placeholder entry (e.g. `.gitkeep`) from whatever
|
|
63
|
+
* .gitignore file currently causes `fileName` to be ignored, used by the
|
|
64
|
+
* opt-in `--remove-git-keep-from-git-ignore` flow (issue #1825).
|
|
65
|
+
*
|
|
66
|
+
* Walks the ignore chain (`git check-ignore -v`) and strips each literal
|
|
67
|
+
* matching line until the placeholder is no longer ignored. Glob rules and
|
|
68
|
+
* ignore sources outside the working tree (global excludes file) are left
|
|
69
|
+
* untouched and cause the removal to report failure so the caller can fall
|
|
70
|
+
* back to a clear message instead of silently mangling the repo.
|
|
71
|
+
*
|
|
72
|
+
* @returns {Promise<{removed: boolean, reason?: string, modifiedFiles: string[], stagedFiles: string[]}>}
|
|
73
|
+
*/
|
|
74
|
+
export async function removePlaceholderFromGitignore({ $, tempDir, fileName }) {
|
|
75
|
+
const repoRoot = path.resolve(tempDir);
|
|
76
|
+
const modifiedFiles = [];
|
|
77
|
+
const stagedFiles = [];
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < 50; i++) {
|
|
80
|
+
const stillIgnored = await $({ cwd: tempDir, silent: true })`git check-ignore ${fileName}`;
|
|
81
|
+
if (stillIgnored.code !== 0) {
|
|
82
|
+
// No longer ignored — done.
|
|
83
|
+
return { removed: true, modifiedFiles, stagedFiles };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const verbose = await $({ cwd: tempDir, silent: true })`git check-ignore -v ${fileName}`;
|
|
87
|
+
const parsed = parseCheckIgnoreVerbose(verbose.stdout ? verbose.stdout.toString() : '');
|
|
88
|
+
if (!parsed) {
|
|
89
|
+
return { removed: false, reason: 'could-not-locate-rule', modifiedFiles, stagedFiles };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Only edit ignore files that live inside the working tree.
|
|
93
|
+
const sourcePath = path.resolve(repoRoot, parsed.source);
|
|
94
|
+
if (sourcePath !== repoRoot && !sourcePath.startsWith(repoRoot + path.sep)) {
|
|
95
|
+
return { removed: false, reason: 'rule-outside-worktree', modifiedFiles, stagedFiles };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let content;
|
|
99
|
+
try {
|
|
100
|
+
content = await fs.readFile(sourcePath, 'utf8');
|
|
101
|
+
} catch {
|
|
102
|
+
return { removed: false, reason: 'cannot-read-ignore-file', modifiedFiles, stagedFiles };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const lines = content.split('\n');
|
|
106
|
+
const targetLine = lines[parsed.lineNum - 1];
|
|
107
|
+
if (targetLine === undefined || !isLiteralIgnoreEntry(targetLine, fileName)) {
|
|
108
|
+
// The rule is a glob (e.g. ".git*") or otherwise not a literal entry we
|
|
109
|
+
// can safely remove — refuse rather than over-editing the user's config.
|
|
110
|
+
return { removed: false, reason: 'rule-not-literal', modifiedFiles, stagedFiles, pattern: parsed.pattern };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
lines.splice(parsed.lineNum - 1, 1);
|
|
114
|
+
await fs.writeFile(sourcePath, lines.join('\n'));
|
|
115
|
+
|
|
116
|
+
const relSource = path.relative(repoRoot, sourcePath);
|
|
117
|
+
if (!modifiedFiles.includes(relSource)) {
|
|
118
|
+
modifiedFiles.push(relSource);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Stage committable ignore files (.gitignore); skip non-committable sources
|
|
122
|
+
// such as .git/info/exclude which the un-ignore already takes effect for.
|
|
123
|
+
const insideGitDir = relSource.split(path.sep)[0] === '.git';
|
|
124
|
+
if (!insideGitDir) {
|
|
125
|
+
const addIgnore = await $({ cwd: tempDir, silent: true })`git add ${relSource}`;
|
|
126
|
+
if (addIgnore.code === 0 && !stagedFiles.includes(relSource)) {
|
|
127
|
+
stagedFiles.push(relSource);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { removed: false, reason: 'too-many-rules', modifiedFiles, stagedFiles };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Convenience wrapper: stage the placeholder and, if it failed solely because
|
|
137
|
+
* the repository gitignores it, stop with a clear user-facing explanation
|
|
138
|
+
* (issue #1825). Keeps the auto-PR caller small (it is near the max-lines
|
|
139
|
+
* budget). Returns the {@link addPlaceholderFileToGit} result for any other
|
|
140
|
+
* outcome so the caller can handle genuine failures as before.
|
|
141
|
+
*
|
|
142
|
+
* @returns {Promise<{code: number, ignored: boolean, action: string, stderr: string, removal?: object}>}
|
|
143
|
+
*/
|
|
144
|
+
export async function stagePlaceholderFileOrExplain(params) {
|
|
145
|
+
const addResult = await addPlaceholderFileToGit(params);
|
|
146
|
+
if (addResult.code !== 0 && addResult.ignored) {
|
|
147
|
+
await reportIgnoredPlaceholderAndThrow({
|
|
148
|
+
fileName: params.fileName,
|
|
149
|
+
issueUrl: params.issueUrl,
|
|
150
|
+
addResult,
|
|
151
|
+
log: params.log,
|
|
152
|
+
formatAligned: params.formatAligned,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return addResult;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Log a clear, friendly explanation of why the auto-PR placeholder could not be
|
|
160
|
+
* committed (it is listed in the repository's .gitignore) and then throw a
|
|
161
|
+
* user-facing error so the run stops without a scary stack trace.
|
|
162
|
+
*
|
|
163
|
+
* This is the default behaviour for issue #1825's follow-up: instead of forcing
|
|
164
|
+
* the commit through, we explain the root cause and let the user choose how to
|
|
165
|
+
* proceed (manual fix, or one of the two opt-in flags). The message deliberately
|
|
166
|
+
* stays environment-agnostic — it only mentions the `solve` / `/solve` options.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} params
|
|
169
|
+
* @param {string} params.fileName - placeholder file name (e.g. `.gitkeep`).
|
|
170
|
+
* @param {string} params.issueUrl - issue URL, used to build copy-paste commands.
|
|
171
|
+
* @param {object} [params.addResult] - result from addPlaceholderFileToGit (for the remove-failed reason).
|
|
172
|
+
* @param {Function} params.log - async logger.
|
|
173
|
+
* @param {Function} params.formatAligned - log line formatter.
|
|
174
|
+
* @throws always — the thrown error carries `hiveMindUserFacingLogged = true`.
|
|
175
|
+
*/
|
|
176
|
+
export async function reportIgnoredPlaceholderAndThrow({ fileName, issueUrl, addResult, log, formatAligned }) {
|
|
177
|
+
const url = issueUrl || '<issue-url>';
|
|
178
|
+
await log('');
|
|
179
|
+
await log(formatAligned('🛑', 'Cannot add placeholder:', `${fileName} is listed in .gitignore`), { level: 'error' });
|
|
180
|
+
await log('');
|
|
181
|
+
await log(' 🔍 Root cause:');
|
|
182
|
+
await log(` The repository's .gitignore matches the temporary placeholder file "${fileName}".`);
|
|
183
|
+
await log(' The placeholder is created only to seed the initial draft pull request and is');
|
|
184
|
+
await log(' removed automatically when the task completes — but git refuses to add an ignored');
|
|
185
|
+
await log(' file, so the initial commit cannot be created.');
|
|
186
|
+
|
|
187
|
+
if (addResult?.action === 'remove-failed') {
|
|
188
|
+
await log('');
|
|
189
|
+
await log(' ⚠️ The ignore rule is not a plain "' + fileName + '" entry, so it cannot be removed');
|
|
190
|
+
await log(' automatically (removing it might un-ignore unrelated files). Resolve it manually');
|
|
191
|
+
await log(' or use --force-git-keep-commit.');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await log('');
|
|
195
|
+
await log(' 💡 How to resolve (pick one):');
|
|
196
|
+
await log(` 1. Remove "${fileName}" from .gitignore in the repository, then re-run.`);
|
|
197
|
+
await log(' 2. Let the tool remove it for you before committing:');
|
|
198
|
+
await log(` solve ${url} --remove-git-keep-from-git-ignore`);
|
|
199
|
+
await log(` /solve ${url} --remove-git-keep-from-git-ignore`);
|
|
200
|
+
await log(' 3. Commit the placeholder anyway, ignoring the .gitignore rule:');
|
|
201
|
+
await log(` solve ${url} --force-git-keep-commit`);
|
|
202
|
+
await log(` /solve ${url} --force-git-keep-commit`);
|
|
203
|
+
await log('');
|
|
204
|
+
|
|
205
|
+
const error = new Error(`Placeholder "${fileName}" is listed in .gitignore; use --remove-git-keep-from-git-ignore or --force-git-keep-commit, or remove it from .gitignore manually.`);
|
|
206
|
+
error.hiveMindUserFacingLogged = true;
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Emit the verbose "git add staged nothing" troubleshooting report and throw.
|
|
212
|
+
*
|
|
213
|
+
* Reached by auto-PR creation when the placeholder file was written but git did
|
|
214
|
+
* not stage any change (e.g. identical content is already tracked, or the file
|
|
215
|
+
* is gitignored in .gitkeep mode). Extracted from solve.auto-pr.lib.mjs to keep
|
|
216
|
+
* that module under the max-lines budget.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} params
|
|
219
|
+
* @param {string} params.fileName - placeholder file name.
|
|
220
|
+
* @param {boolean} params.useClaudeFile - true for CLAUDE.md mode, false for .gitkeep mode.
|
|
221
|
+
* @param {string} params.tempDir - repository working directory.
|
|
222
|
+
* @param {string} params.branchName - target branch (debug info).
|
|
223
|
+
* @param {boolean} params.existingContent - whether the file already existed.
|
|
224
|
+
* @param {Function} params.log - async logger.
|
|
225
|
+
* @param {Function} params.formatAligned - log line formatter.
|
|
226
|
+
* @throws always.
|
|
227
|
+
*/
|
|
228
|
+
export async function explainNothingStagedAndThrow({ fileName, useClaudeFile, tempDir, branchName, existingContent, log, formatAligned }) {
|
|
229
|
+
await log('');
|
|
230
|
+
await log(formatAligned('❌', 'GIT ADD FAILED:', 'Nothing was staged'), { level: 'error' });
|
|
231
|
+
await log('');
|
|
232
|
+
await log(' 🔍 What happened:');
|
|
233
|
+
await log(` ${fileName} was created but git did not stage any changes.`);
|
|
234
|
+
await log('');
|
|
235
|
+
await log(' 💡 Possible causes:');
|
|
236
|
+
await log(` • ${fileName} already exists with identical content`);
|
|
237
|
+
await log(' • File system sync issue');
|
|
238
|
+
if (!useClaudeFile) {
|
|
239
|
+
await log(` • ${fileName} is in .gitignore`);
|
|
240
|
+
}
|
|
241
|
+
await log('');
|
|
242
|
+
await log(' 🔧 Troubleshooting steps:');
|
|
243
|
+
await log(` 1. Check file exists: ls -la "${tempDir}/${fileName}"`);
|
|
244
|
+
await log(` 2. Check git status: cd "${tempDir}" && git status`);
|
|
245
|
+
if (useClaudeFile) {
|
|
246
|
+
await log(` 3. Force add: cd "${tempDir}" && git add -f ${fileName}`);
|
|
247
|
+
} else {
|
|
248
|
+
await log(` 3. Check if ignored: cd "${tempDir}" && git check-ignore ${fileName}`);
|
|
249
|
+
await log(` 4. Force add: cd "${tempDir}" && git add -f ${fileName}`);
|
|
250
|
+
}
|
|
251
|
+
await log('');
|
|
252
|
+
await log(' 📂 Debug information:');
|
|
253
|
+
await log(` Working directory: ${tempDir}`);
|
|
254
|
+
await log(` Branch: ${branchName}`);
|
|
255
|
+
if (!useClaudeFile) {
|
|
256
|
+
await log(' Mode: .gitkeep');
|
|
257
|
+
}
|
|
258
|
+
if (existingContent) {
|
|
259
|
+
await log(` Note: ${fileName} already existed (attempted to update with timestamp)`);
|
|
260
|
+
}
|
|
261
|
+
await log('');
|
|
262
|
+
throw new Error(`Git add staged nothing - ${fileName} may be unchanged${useClaudeFile ? '' : ' or ignored'}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
8
265
|
/**
|
|
9
266
|
* Stage the temporary placeholder file (CLAUDE.md or .gitkeep) used to seed the
|
|
10
267
|
* initial auto-PR commit.
|
|
@@ -14,13 +271,18 @@
|
|
|
14
271
|
* cleanupClaudeFile in solve.results.lib.mjs). When the target repository's
|
|
15
272
|
* .gitignore matches the placeholder — issue #1825: e.g. rumaster/tg-games
|
|
16
273
|
* ignores `.gitkeep` — a plain `git add <file>` exits non-zero with
|
|
17
|
-
* "The following paths are ignored by one of your .gitignore files"
|
|
18
|
-
*
|
|
274
|
+
* "The following paths are ignored by one of your .gitignore files".
|
|
275
|
+
*
|
|
276
|
+
* Behaviour when the placeholder is git-ignored (issue #1825 follow-up):
|
|
277
|
+
* - Default: do NOT force anything. Return `action: 'blocked'` so the caller
|
|
278
|
+
* can explain the root cause and offer the opt-in flags below.
|
|
279
|
+
* - `--remove-git-keep-from-git-ignore`: strip the literal placeholder entry
|
|
280
|
+
* from .gitignore, then add normally (`action: 'removed-from-gitignore'`).
|
|
281
|
+
* - `--force-git-keep-commit`: keep the previous behaviour and force-add with
|
|
282
|
+
* `git add -f` (`action: 'forced'`).
|
|
19
283
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* `git add -f`. Force-adding only happens for the ignored-placeholder case;
|
|
23
|
-
* any other add failure is surfaced unchanged so genuine errors are not masked.
|
|
284
|
+
* Any add failure that is NOT caused by .gitignore is surfaced unchanged
|
|
285
|
+
* (`action: 'failed'`) so genuine errors are not masked.
|
|
24
286
|
*
|
|
25
287
|
* @param {object} params
|
|
26
288
|
* @param {Function} params.$ - command-stream tagged-template runner.
|
|
@@ -29,15 +291,17 @@
|
|
|
29
291
|
* @param {Function} [params.log] - async logger.
|
|
30
292
|
* @param {Function} [params.formatAligned] - log line formatter.
|
|
31
293
|
* @param {boolean} [params.verbose] - emit verbose diagnostics.
|
|
32
|
-
* @
|
|
294
|
+
* @param {boolean} [params.forceGitKeepCommit] - force-add even when ignored.
|
|
295
|
+
* @param {boolean} [params.removeGitKeepFromGitIgnore] - remove the .gitignore entry first.
|
|
296
|
+
* @returns {Promise<{code: number, ignored: boolean, action: string, stderr: string, removal?: object}>}
|
|
33
297
|
*/
|
|
34
|
-
export async function addPlaceholderFileToGit({ $, tempDir, fileName, log, formatAligned, verbose = false }) {
|
|
298
|
+
export async function addPlaceholderFileToGit({ $, tempDir, fileName, log, formatAligned, verbose = false, forceGitKeepCommit = false, removeGitKeepFromGitIgnore = false }) {
|
|
35
299
|
// Run silently: `git add` is quiet on success and only emits the noisy
|
|
36
300
|
// "paths are ignored ... Use -f" hint on failure, which we capture in
|
|
37
301
|
// `stderr` and re-surface from the caller only when the failure is genuine.
|
|
38
302
|
const addResult = await $({ cwd: tempDir, silent: true })`git add ${fileName}`;
|
|
39
303
|
if (addResult.code === 0) {
|
|
40
|
-
return { code: 0,
|
|
304
|
+
return { code: 0, ignored: false, action: 'added', stderr: '' };
|
|
41
305
|
}
|
|
42
306
|
|
|
43
307
|
const stderr = addResult.stderr ? addResult.stderr.toString() : '';
|
|
@@ -50,21 +314,50 @@ export async function addPlaceholderFileToGit({ $, tempDir, fileName, log, forma
|
|
|
50
314
|
if (!ignored) {
|
|
51
315
|
// The failure was not caused by .gitignore — surface the original error so
|
|
52
316
|
// genuine problems (permissions, corrupt index, ...) are not masked.
|
|
53
|
-
return { code: addResult.code,
|
|
317
|
+
return { code: addResult.code, ignored: false, action: 'failed', stderr };
|
|
54
318
|
}
|
|
55
319
|
|
|
56
|
-
|
|
57
|
-
|
|
320
|
+
// The placeholder is ignored. Resolve based on the opt-in flags.
|
|
321
|
+
if (removeGitKeepFromGitIgnore) {
|
|
322
|
+
if (log && formatAligned) {
|
|
323
|
+
await log(formatAligned('ℹ️', `${fileName} is ignored:`, 'Removing it from .gitignore (--remove-git-keep-from-git-ignore)'));
|
|
324
|
+
}
|
|
325
|
+
const removal = await removePlaceholderFromGitignore({ $, tempDir, fileName });
|
|
326
|
+
if (!removal.removed) {
|
|
327
|
+
// Could not safely remove (glob rule, external source, ...). Block with
|
|
328
|
+
// detail so the caller can explain and suggest --force-git-keep-commit.
|
|
329
|
+
return { code: addResult.code, ignored: true, action: 'remove-failed', stderr, removal };
|
|
330
|
+
}
|
|
331
|
+
if (verbose && log) {
|
|
332
|
+
await log(` Removed ${fileName} from: ${removal.modifiedFiles.join(', ') || '(none)'}`, { verbose: true });
|
|
333
|
+
}
|
|
334
|
+
const retry = await $({ cwd: tempDir, silent: true })`git add ${fileName}`;
|
|
335
|
+
return {
|
|
336
|
+
code: retry.code,
|
|
337
|
+
ignored: true,
|
|
338
|
+
action: 'removed-from-gitignore',
|
|
339
|
+
stderr: retry.stderr ? retry.stderr.toString() : '',
|
|
340
|
+
removal,
|
|
341
|
+
};
|
|
58
342
|
}
|
|
59
|
-
|
|
60
|
-
|
|
343
|
+
|
|
344
|
+
if (forceGitKeepCommit) {
|
|
345
|
+
if (log && formatAligned) {
|
|
346
|
+
await log(formatAligned('ℹ️', `${fileName} is ignored:`, 'Force-adding placeholder (--force-git-keep-commit)'));
|
|
347
|
+
}
|
|
348
|
+
if (verbose && log) {
|
|
349
|
+
await log(` ${fileName} matched a .gitignore rule; --force-git-keep-commit is set, retrying with: git add -f ${fileName}`, { verbose: true });
|
|
350
|
+
}
|
|
351
|
+
const forcedResult = await $({ cwd: tempDir, silent: true })`git add -f ${fileName}`;
|
|
352
|
+
return {
|
|
353
|
+
code: forcedResult.code,
|
|
354
|
+
ignored: true,
|
|
355
|
+
action: 'forced',
|
|
356
|
+
stderr: forcedResult.stderr ? forcedResult.stderr.toString() : '',
|
|
357
|
+
};
|
|
61
358
|
}
|
|
62
359
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
forced: true,
|
|
67
|
-
ignored: true,
|
|
68
|
-
stderr: forcedResult.stderr ? forcedResult.stderr.toString() : '',
|
|
69
|
-
};
|
|
360
|
+
// Default: do not force through. Let the caller explain the root cause and
|
|
361
|
+
// offer the opt-in flags or a manual fix (issue #1825 follow-up).
|
|
362
|
+
return { code: addResult.code, ignored: true, action: 'blocked', stderr };
|
|
70
363
|
}
|
|
@@ -8,7 +8,7 @@ import { handleRejectedPushForAutoPr, synchronizeExistingIssueBranchBeforeAutoPr
|
|
|
8
8
|
import { emitForkAwareDiagnostic } from './solve.auto-pr-fork-diagnostic.lib.mjs';
|
|
9
9
|
|
|
10
10
|
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. Issue #1756: execGhWithRetry retries on transient 5xx (504) too.
|
|
11
|
-
import {
|
|
11
|
+
import { stagePlaceholderFileOrExplain, explainNothingStagedAndThrow } from './solve.auto-pr-placeholder.lib.mjs'; // Issue #1825: handles the seed placeholder when the target repo gitignores it.
|
|
12
12
|
|
|
13
13
|
export async function handleAutoPrCreation({ argv, tempDir, branchName, issueNumber, owner, repo, defaultBranch, forkedRepo, isContinueMode, prNumber, log, formatAligned, $, reportError, path, fs }) {
|
|
14
14
|
// Skip auto-PR creation if:
|
|
@@ -167,9 +167,22 @@ Proceed.
|
|
|
167
167
|
// Add and commit the file
|
|
168
168
|
await log(formatAligned('📦', 'Adding file:', 'To git staging'));
|
|
169
169
|
|
|
170
|
-
// Issue #1825:
|
|
171
|
-
//
|
|
172
|
-
|
|
170
|
+
// Issue #1825: by default we no longer force the placeholder through when
|
|
171
|
+
// the target repo gitignores it. stagePlaceholderFileOrExplain stops with a
|
|
172
|
+
// clear root-cause message unless --force-git-keep-commit /
|
|
173
|
+
// --remove-git-keep-from-git-ignore is set. Shared opts are reused by the
|
|
174
|
+
// .gitkeep fallback below.
|
|
175
|
+
const placeholderStageOpts = {
|
|
176
|
+
$,
|
|
177
|
+
tempDir,
|
|
178
|
+
log,
|
|
179
|
+
formatAligned,
|
|
180
|
+
verbose: argv.verbose,
|
|
181
|
+
issueUrl,
|
|
182
|
+
forceGitKeepCommit: argv.forceGitKeepCommit,
|
|
183
|
+
removeGitKeepFromGitIgnore: argv.removeGitKeepFromGitIgnore,
|
|
184
|
+
};
|
|
185
|
+
const addResult = await stagePlaceholderFileOrExplain({ ...placeholderStageOpts, fileName });
|
|
173
186
|
|
|
174
187
|
if (addResult.code !== 0) {
|
|
175
188
|
await log(`❌ Failed to add ${fileName}`, { level: 'error' });
|
|
@@ -214,14 +227,13 @@ Proceed.
|
|
|
214
227
|
await fs.writeFile(gitkeepPath, gitkeepContent);
|
|
215
228
|
await log(formatAligned('✅', 'Created:', '.gitkeep file'));
|
|
216
229
|
|
|
217
|
-
// Try to add .gitkeep
|
|
218
|
-
|
|
230
|
+
// Try to add .gitkeep. If it too is gitignored, honor the opt-in
|
|
231
|
+
// flags or explain the root cause (issue #1825).
|
|
232
|
+
const gitkeepAddResult = await stagePlaceholderFileOrExplain({ ...placeholderStageOpts, fileName: '.gitkeep' });
|
|
219
233
|
|
|
220
234
|
if (gitkeepAddResult.code !== 0) {
|
|
221
235
|
await log('❌ Failed to add .gitkeep', { level: 'error' });
|
|
222
|
-
await log(` Error: ${gitkeepAddResult.stderr || 'Unknown error'}`, {
|
|
223
|
-
level: 'error',
|
|
224
|
-
});
|
|
236
|
+
await log(` Error: ${gitkeepAddResult.stderr || 'Unknown error'}`, { level: 'error' });
|
|
225
237
|
throw new Error('Failed to add .gitkeep');
|
|
226
238
|
}
|
|
227
239
|
|
|
@@ -231,9 +243,7 @@ Proceed.
|
|
|
231
243
|
|
|
232
244
|
if (!gitStatus || gitStatus.length === 0) {
|
|
233
245
|
await log('');
|
|
234
|
-
await log(formatAligned('❌', 'GIT ADD FAILED:', 'Neither CLAUDE.md nor .gitkeep could be staged'), {
|
|
235
|
-
level: 'error',
|
|
236
|
-
});
|
|
246
|
+
await log(formatAligned('❌', 'GIT ADD FAILED:', 'Neither CLAUDE.md nor .gitkeep could be staged'), { level: 'error' });
|
|
237
247
|
await log('');
|
|
238
248
|
await log(' 🔍 What happened:');
|
|
239
249
|
await log(' Both CLAUDE.md and .gitkeep failed to stage.');
|
|
@@ -249,58 +259,11 @@ Proceed.
|
|
|
249
259
|
commitFileName = '.gitkeep';
|
|
250
260
|
await log(formatAligned('✅', 'File staged:', '.gitkeep'));
|
|
251
261
|
} else {
|
|
252
|
-
await
|
|
253
|
-
await log(formatAligned('❌', 'GIT ADD FAILED:', 'Nothing was staged'), { level: 'error' });
|
|
254
|
-
await log('');
|
|
255
|
-
await log(' 🔍 What happened:');
|
|
256
|
-
await log(' CLAUDE.md was created but git did not stage any changes.');
|
|
257
|
-
await log('');
|
|
258
|
-
await log(' 💡 Possible causes:');
|
|
259
|
-
await log(' • CLAUDE.md already exists with identical content');
|
|
260
|
-
await log(' • File system sync issue');
|
|
261
|
-
await log('');
|
|
262
|
-
await log(' 🔧 Troubleshooting steps:');
|
|
263
|
-
await log(` 1. Check file exists: ls -la "${tempDir}/CLAUDE.md"`);
|
|
264
|
-
await log(` 2. Check git status: cd "${tempDir}" && git status`);
|
|
265
|
-
await log(` 3. Force add: cd "${tempDir}" && git add -f CLAUDE.md`);
|
|
266
|
-
await log('');
|
|
267
|
-
await log(' 📂 Debug information:');
|
|
268
|
-
await log(` Working directory: ${tempDir}`);
|
|
269
|
-
await log(` Branch: ${branchName}`);
|
|
270
|
-
if (existingContent) {
|
|
271
|
-
await log(' Note: CLAUDE.md already existed (attempted to update with timestamp)');
|
|
272
|
-
}
|
|
273
|
-
await log('');
|
|
274
|
-
throw new Error('Git add staged nothing - CLAUDE.md may be unchanged');
|
|
262
|
+
await explainNothingStagedAndThrow({ fileName: 'CLAUDE.md', useClaudeFile: true, tempDir, branchName, existingContent, log, formatAligned });
|
|
275
263
|
}
|
|
276
264
|
} else {
|
|
277
265
|
// In --gitkeep-file mode, if .gitkeep couldn't be staged, this is an error
|
|
278
|
-
await log
|
|
279
|
-
await log(formatAligned('❌', 'GIT ADD FAILED:', 'Nothing was staged'), { level: 'error' });
|
|
280
|
-
await log('');
|
|
281
|
-
await log(' 🔍 What happened:');
|
|
282
|
-
await log(` ${fileName} was created but git did not stage any changes.`);
|
|
283
|
-
await log('');
|
|
284
|
-
await log(' 💡 Possible causes:');
|
|
285
|
-
await log(` • ${fileName} already exists with identical content`);
|
|
286
|
-
await log(' • File system sync issue');
|
|
287
|
-
await log(` • ${fileName} is in .gitignore`);
|
|
288
|
-
await log('');
|
|
289
|
-
await log(' 🔧 Troubleshooting steps:');
|
|
290
|
-
await log(` 1. Check file exists: ls -la "${tempDir}/${fileName}"`);
|
|
291
|
-
await log(` 2. Check git status: cd "${tempDir}" && git status`);
|
|
292
|
-
await log(` 3. Check if ignored: cd "${tempDir}" && git check-ignore ${fileName}`);
|
|
293
|
-
await log(` 4. Force add: cd "${tempDir}" && git add -f ${fileName}`);
|
|
294
|
-
await log('');
|
|
295
|
-
await log(' 📂 Debug information:');
|
|
296
|
-
await log(` Working directory: ${tempDir}`);
|
|
297
|
-
await log(` Branch: ${branchName}`);
|
|
298
|
-
await log(` Mode: ${useClaudeFile ? 'CLAUDE.md' : '.gitkeep'}`);
|
|
299
|
-
if (existingContent) {
|
|
300
|
-
await log(` Note: ${fileName} already existed (attempted to update with timestamp)`);
|
|
301
|
-
}
|
|
302
|
-
await log('');
|
|
303
|
-
throw new Error(`Git add staged nothing - ${fileName} may be unchanged or ignored`);
|
|
266
|
+
await explainNothingStagedAndThrow({ fileName, useClaudeFile: false, tempDir, branchName, existingContent, log, formatAligned });
|
|
304
267
|
}
|
|
305
268
|
}
|
|
306
269
|
|
|
@@ -419,9 +382,7 @@ Proceed.
|
|
|
419
382
|
|
|
420
383
|
// Check for archived repository error
|
|
421
384
|
if (errorOutput.includes('archived') && errorOutput.includes('read-only')) {
|
|
422
|
-
await log(`\n${formatAligned('❌', 'REPOSITORY ARCHIVED:', 'Cannot push to archived repository')}`, {
|
|
423
|
-
level: 'error',
|
|
424
|
-
});
|
|
385
|
+
await log(`\n${formatAligned('❌', 'REPOSITORY ARCHIVED:', 'Cannot push to archived repository')}`, { level: 'error' });
|
|
425
386
|
await log('');
|
|
426
387
|
await log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
427
388
|
await log('');
|
|
@@ -717,9 +678,7 @@ Proceed.
|
|
|
717
678
|
} else if (parentRepo !== `${owner}/${repo}` && sourceRepo !== `${owner}/${repo}`) {
|
|
718
679
|
// Repository IS a fork, but of a different repository
|
|
719
680
|
await log('');
|
|
720
|
-
await log(formatAligned('❌', 'WRONG FORK PARENT:', 'Fork is from different repository'), {
|
|
721
|
-
level: 'error',
|
|
722
|
-
});
|
|
681
|
+
await log(formatAligned('❌', 'WRONG FORK PARENT:', 'Fork is from different repository'), { level: 'error' });
|
|
723
682
|
await log('');
|
|
724
683
|
await log(' 🔍 What happened:');
|
|
725
684
|
await log(` The repository ${forkedRepo} IS a GitHub fork,`);
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -110,6 +110,16 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
110
110
|
description: 'Automatically use .gitkeep if CLAUDE.md is in .gitignore (pre-checks before creating file)',
|
|
111
111
|
default: true,
|
|
112
112
|
},
|
|
113
|
+
'force-git-keep-commit': {
|
|
114
|
+
type: 'boolean',
|
|
115
|
+
description: 'If the auto-PR placeholder (.gitkeep) is listed in .gitignore, commit it anyway with `git add -f` instead of stopping (issue #1825). Off by default.',
|
|
116
|
+
default: false,
|
|
117
|
+
},
|
|
118
|
+
'remove-git-keep-from-git-ignore': {
|
|
119
|
+
type: 'boolean',
|
|
120
|
+
description: 'If the auto-PR placeholder (.gitkeep) is listed in .gitignore, remove that entry from .gitignore first, then commit normally (issue #1825). Off by default.',
|
|
121
|
+
default: false,
|
|
122
|
+
},
|
|
113
123
|
'auto-support-agents-md-as-claude-md': {
|
|
114
124
|
type: 'boolean',
|
|
115
125
|
description: '[EXPERIMENTAL] Temporarily copy AGENTS.md/agents.md to CLAUDE.md while Claude runs, then remove the temporary copy',
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
import { reportError } from './sentry.lib.mjs';
|
|
8
8
|
|
|
9
9
|
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
|
|
10
|
+
// Issue #1827: tool-generated comments (markers + in-memory tracked IDs) must
|
|
11
|
+
// not count as feedback in watch/continue mode, mirroring checkForNonBotComments.
|
|
12
|
+
import { isToolGeneratedComment, isToolTrackedCommentId } from './tool-comments.lib.mjs';
|
|
10
13
|
export const detectAndCountFeedback = async params => {
|
|
11
14
|
const { prNumber, branchName, owner, repo, issueNumber, isContinueMode, argv, mergeStateStatus, prState, workStartTime, log, formatAligned, cleanErrorMessage, $, repositoryPath = null } = params;
|
|
12
15
|
|
|
@@ -93,6 +96,14 @@ export const detectAndCountFeedback = async params => {
|
|
|
93
96
|
// Define log patterns to filter out comments containing logs from solve.mjs
|
|
94
97
|
const logPatterns = [/📊.*Log file|solution\s+draft.*log/i, /🔗.*Link:|💻.*Session:/i, /Generated with.*solve\.mjs/i, /Session ID:|Log file available:/i];
|
|
95
98
|
|
|
99
|
+
// Issue #1827: A comment is tool-generated if its ID was tracked in
|
|
100
|
+
// memory during this run (system status comments AND the agent's own
|
|
101
|
+
// session comments) or if its body carries a known tool marker (catches
|
|
102
|
+
// comments from previous runs whose IDs are gone). These must never
|
|
103
|
+
// count as feedback — otherwise the agent's own "CI now green" / status
|
|
104
|
+
// comments trigger an endless restart loop (see PR link-foundation/rust-web-box#34).
|
|
105
|
+
const isToolComment = comment => isToolTrackedCommentId(comment.id) || isToolGeneratedComment(comment.body);
|
|
106
|
+
|
|
96
107
|
// Count new PR comments after last commit (both code review comments and conversation comments)
|
|
97
108
|
let prReviewComments = [];
|
|
98
109
|
let prConversationComments = [];
|
|
@@ -112,6 +123,10 @@ export const detectAndCountFeedback = async params => {
|
|
|
112
123
|
|
|
113
124
|
// Helper function to filter comments based on time and log patterns
|
|
114
125
|
const filterComment = comment => {
|
|
126
|
+
// Issue #1827: never count tool-generated comments as feedback.
|
|
127
|
+
if (isToolComment(comment)) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
115
130
|
const commentTime = new Date(comment.created_at);
|
|
116
131
|
const isAfterCommit = commentTime > lastCommitTime;
|
|
117
132
|
const isNotLogPattern = !logPatterns.some(pattern => pattern.test(comment.body || ''));
|
|
@@ -145,6 +160,10 @@ export const detectAndCountFeedback = async params => {
|
|
|
145
160
|
if (issueCommentsResult.code === 0) {
|
|
146
161
|
const issueComments = JSON.parse(issueCommentsResult.stdout.toString());
|
|
147
162
|
const filteredIssueComments = issueComments.filter(comment => {
|
|
163
|
+
// Issue #1827: never count tool-generated comments as feedback.
|
|
164
|
+
if (isToolComment(comment)) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
148
167
|
const commentTime = new Date(comment.created_at);
|
|
149
168
|
const isAfterCommit = commentTime > lastCommitTime;
|
|
150
169
|
const isNotLogPattern = !logPatterns.some(pattern => pattern.test(comment.body || ''));
|
package/src/solve.watch.lib.mjs
CHANGED
|
@@ -46,6 +46,12 @@ const { formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIte
|
|
|
46
46
|
const toolComments = await import('./tool-comments.lib.mjs');
|
|
47
47
|
const { AUTO_RESTART_MARKER, postTrackedComment } = toolComments;
|
|
48
48
|
|
|
49
|
+
// Issue #1827: After each AI session, register the authenticated account's own
|
|
50
|
+
// comments (free-form status updates the agent posts itself) so the next
|
|
51
|
+
// detectAndCountFeedback() call doesn't mistake them for new human feedback.
|
|
52
|
+
const autoMergeHelpers = await import('./solve.auto-merge-helpers.lib.mjs');
|
|
53
|
+
const { trackAuthenticatedUserCommentsSince } = autoMergeHelpers;
|
|
54
|
+
|
|
49
55
|
// Issue #1728: Per-iteration working session summary attachment helper
|
|
50
56
|
// Issue #1763: Per-iteration PR ↔ issue link verification (in case the AI
|
|
51
57
|
// agent overwrites the PR body without a closing keyword and the iteration
|
|
@@ -340,6 +346,24 @@ export const watchForFeedback = async params => {
|
|
|
340
346
|
global.previousSessionId = toolResult.sessionId;
|
|
341
347
|
}
|
|
342
348
|
|
|
349
|
+
// Issue #1827: Track the authenticated account's own comments posted
|
|
350
|
+
// during this session window so they are filtered (by ID) on the next
|
|
351
|
+
// feedback check instead of re-triggering a restart.
|
|
352
|
+
try {
|
|
353
|
+
const tracked = await trackAuthenticatedUserCommentsSince(owner, repo, prNumber, issueNumber, iterationStartTime, $, { verbose: argv.verbose });
|
|
354
|
+
if (argv.verbose && tracked.length > 0) {
|
|
355
|
+
await log(formatAligned('🧷', 'Tracked own session comments:', `${tracked.length} (won't count as feedback)`, 2));
|
|
356
|
+
}
|
|
357
|
+
} catch (trackError) {
|
|
358
|
+
reportError(trackError, {
|
|
359
|
+
context: 'track_authenticated_user_session_comments',
|
|
360
|
+
prNumber,
|
|
361
|
+
owner,
|
|
362
|
+
repo,
|
|
363
|
+
operation: 'track_session_comments',
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
343
367
|
if (!toolResult.success) {
|
|
344
368
|
// Check if this is an API error using shared utility
|
|
345
369
|
if (isApiError(toolResult)) {
|