@link-assistant/hive-mind 2.15.0 → 2.15.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 +40 -0
- package/package.json +1 -1
- package/src/git-auth-transport.lib.mjs +309 -0
- package/src/github-merge.lib.mjs +24 -28
- package/src/lib.mjs +7 -0
- package/src/merge-error-classification.lib.mjs +134 -0
- package/src/option-suggestions.lib.mjs +1 -0
- package/src/pr-draft-state.lib.mjs +96 -0
- package/src/review.mjs +6 -0
- package/src/solve.auto-merge-attempt.lib.mjs +42 -3
- package/src/solve.auto-merge-guards.lib.mjs +152 -0
- package/src/solve.auto-merge-helpers.lib.mjs +20 -1
- package/src/solve.auto-merge-preflight.lib.mjs +122 -0
- package/src/solve.auto-merge.lib.mjs +70 -84
- package/src/solve.config.lib.mjs +8 -0
- package/src/solve.interrupt.lib.mjs +18 -1
- package/src/solve.mjs +19 -0
- package/src/solve.repo-setup.lib.mjs +10 -0
- package/src/solve.repository.lib.mjs +26 -0
- package/src/solve.restart-shared.lib.mjs +302 -278
- package/src/solve.results.lib.mjs +6 -12
- package/src/solve.session.lib.mjs +15 -4
- package/src/telegram-merge-queue.lib.mjs +3 -1
- package/src/telegram-merge-wait.lib.mjs +7 -0
- package/src/transient-errors.lib.mjs +34 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.15.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c1f6a41: Authenticate git downloads so a run no longer dies with `Reason: Repository setup failed` (issue #2192).
|
|
8
|
+
|
|
9
|
+
A run ended after three clone attempts, each answered by GitHub with `fatal: remote error: GitHub is temporarily limiting some unauthenticated downloads to protect the stability of the platform. Please retry later or authenticate.` — while every `gh api` call in the same process succeeded. Three defects lined up, and each is fixed:
|
|
10
|
+
|
|
11
|
+
- **Public clones were sent anonymously even though a token was available.** `gh auth setup-git` installs a credential helper, but git only consults a helper after the server answers `401`, and github.com answers `200` for a public repository. Measured with `GIT_TRACE_CURL=1`, a public `git clone`/`gh repo clone` sent **0** `Authorization` headers. The new `src/git-auth-transport.lib.mjs` sends the token preemptively via `http.https://github.com/.extraheader`, injected through `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` environment variables (**3** headers now sent) — so the token never reaches `.git/config` or a command line, and every git child process (`clone`, `fetch`, `pull`, `push`, `gh repo clone`) inherits it. `HIVE_MIND_DISABLE_GIT_AUTH_TRANSPORT=1` opts out.
|
|
12
|
+
- **The refusal was reported as `Unknown error`.** `classifyCloneError` now returns `ANONYMOUS_RATE_LIMIT`, and `src/transient-errors.lib.mjs` gains the shared `github-anonymous-rate-limit` category, so the log names the real cause and the "How to fix" section stops suggesting `gh auth login` to an already-logged-in run.
|
|
13
|
+
- **Retries only slept.** The clone loop and `gitCmdRetry` now upgrade the transport before the next attempt, falling back to `gh-setup-git-identity --repair` (non-interactive) when no token is reachable; only a genuinely absent token still fails.
|
|
14
|
+
|
|
15
|
+
Authentication now happens _before_ the first clone in `setupRepositoryAndClone`, `review`, and `create-test-repo`. Full analysis, the run log excerpts and a reproduction script are in `docs/case-studies/issue-2192/`.
|
|
16
|
+
|
|
17
|
+
## 2.15.1
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- 98fb373: Guarantee that a working session converts its pull request back to "ready for review" (issue #2182).
|
|
22
|
+
|
|
23
|
+
A task ran for 4d 12h 13m 35s and printed `✅ PR IS MERGEABLE!` 2692 times, each followed by `GraphQL: Pull Request is still a draft (mergePullRequest)`. The pull request was a draft because hive-mind had put it there and never took it back out: over the whole 102 244-line run it performed exactly one draft/ready conversion — `Converting PR: To draft mode` — and zero conversions back. The only draft → ready transition came from the AI model itself, running `gh pr ready 142` because the prompt asked it to.
|
|
24
|
+
|
|
25
|
+
**The state machine is now symmetric.** `pr-draft-state.lib.mjs` tracks every draft it hands out, so the matching ready conversion is guaranteed by code rather than requested from the AI:
|
|
26
|
+
|
|
27
|
+
- `executeToolIteration` converts the pull request back to ready in a `finally` block, so a crash, an API error or an aborted tool process still ends the iteration with a mergeable pull request. Previously it drafted the pull request (issue #2123) and had no counterpart at all.
|
|
28
|
+
- `endWorkSession` performs the ready conversion unconditionally. It used to be gated behind `isContinueMode`, which was `false` for the entire reported run, so the one place responsible for the transition never ran. Only the session _comments_ stay gated — they are `--watch`/`--auto-continue` reporting, not state.
|
|
29
|
+
- `solve.mjs` converts the pull request to ready **before** starting the auto-merge watch loop. The AI working session is over at that point; the loop that follows can run for days, and `endWorkSession()` sits behind it.
|
|
30
|
+
- The CTRL+C handler and the fatal-error handler drain the outstanding-draft registry, so an aborted session cannot leave a pull request permanently unmergeable. On interrupt this runs before the log upload, which can be cut off by the isolation backend's SIGKILL (issue #2052).
|
|
31
|
+
- `solve.results.lib.mjs` no longer shells out to `gh pr ready` inline; every transition goes through the state machine, so merged/closed pull requests are skipped and the registry stays accurate.
|
|
32
|
+
|
|
33
|
+
The prompt line asking the AI to mark the pull request ready stays, but nothing depends on it any more.
|
|
34
|
+
|
|
35
|
+
**Defence in depth** — each of these alone would also have ended the reported run, and they bound the damage of a draft pull request whatever its origin:
|
|
36
|
+
|
|
37
|
+
- **`checkPRMergeable` ignored `isDraft`.** A draft pull request with no other blockers reports `mergeable: MERGEABLE` with `mergeStateStatus: CLEAN` — GitHub does not return `DRAFT` there — so the old `mergeable === 'MERGEABLE'` test said yes. Mergeability is now decided by `evaluatePullRequestMergeability`, which treats a draft as not mergeable and reports why. `getMergeBlockers` emits a `draft` blocker on both its normal path and the early "checks have not started yet" path.
|
|
38
|
+
- **Merge failures were unclassified.** Every failed `gh pr merge` was logged as "Will continue monitoring...", regardless of cause. `classifyMergeError` now sorts the error into draft/conflict/blocked/closed/permission/not-mergeable/unknown, the loop self-heals a draft up to three times by marking the pull request ready, and any category stops after `MAX_CONSECUTIVE_MERGE_FAILURES` (3) instead of retrying indefinitely.
|
|
39
|
+
- **The watch loop had no wall-clock limit.** `--auto-restart-until-mergeable-timeout-hours` is new and defaults to 24; the loop now checks elapsed time on every pass and stops with a `watch_timeout` reason.
|
|
40
|
+
|
|
41
|
+
The single-shot merge attempt, the Telegram merge queue and its wait loop use the same classification, so a draft is skipped with the real reason instead of timing out. Every draft/ready conversion now logs the reason it was made, so the log answers "who drafted this and who was supposed to undo it" directly. Full analysis, the run log excerpts and a reproduction script are in `docs/case-studies/issue-2182/`.
|
|
42
|
+
|
|
3
43
|
## 2.15.0
|
|
4
44
|
|
|
5
45
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Authenticated git HTTPS transport for github.com (issue #2192).
|
|
5
|
+
*
|
|
6
|
+
* A `solve` run died with `Reason: Repository setup failed` after three clone
|
|
7
|
+
* attempts, each rejected by GitHub with:
|
|
8
|
+
*
|
|
9
|
+
* fatal: remote error: GitHub is temporarily limiting some unauthenticated
|
|
10
|
+
* downloads to protect the stability of the platform. Please retry later or
|
|
11
|
+
* authenticate.
|
|
12
|
+
*
|
|
13
|
+
* The container *was* authenticated: every `gh` API call in the same run
|
|
14
|
+
* succeeded, and `gh auth setup-git` had installed
|
|
15
|
+
* `credential.https://github.com.helper = !gh auth git-credential`.
|
|
16
|
+
*
|
|
17
|
+
* A credential helper does not help here. git only asks the helper for
|
|
18
|
+
* credentials **after** the server answers `401`, and github.com answers `200`
|
|
19
|
+
* for a public repository — so a public clone is performed anonymously even
|
|
20
|
+
* when a token is sitting right there. Verified with `GIT_TRACE_CURL=1`:
|
|
21
|
+
* `git clone` and `gh repo clone` of a public repository send zero
|
|
22
|
+
* `Authorization` headers with the helper configured (see
|
|
23
|
+
* `experiments/issue-2192-anonymous-clone-auth.mjs`). Anonymous traffic is what
|
|
24
|
+
* GitHub throttles, so the run hit the limit and no amount of retrying or
|
|
25
|
+
* credential-helper repair could have fixed it.
|
|
26
|
+
*
|
|
27
|
+
* The remedy is the one GitHub's own `actions/checkout` uses: send the token
|
|
28
|
+
* preemptively via `http.<host>.extraheader`. Two properties matter here:
|
|
29
|
+
*
|
|
30
|
+
* 1. The header is injected through `GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_n` /
|
|
31
|
+
* `GIT_CONFIG_VALUE_n` environment variables (git >= 2.31) rather than
|
|
32
|
+
* written into `.git/config` or passed as `-c` arguments. The token never
|
|
33
|
+
* lands in a file the AI session can read back, never appears in a
|
|
34
|
+
* process command line visible to `ps`, and is inherited by every git
|
|
35
|
+
* child process — `gh repo clone`, `git fetch`, `git pull`, `git push` —
|
|
36
|
+
* without touching those ~36 call sites.
|
|
37
|
+
* 2. Existing `GIT_CONFIG_*` entries are preserved: the new keys are appended
|
|
38
|
+
* after whatever the operator (or an outer container) already configured.
|
|
39
|
+
*
|
|
40
|
+
* The token value is protected in logs by the existing sanitizer: base64 forms
|
|
41
|
+
* of known local tokens are masked by `findEncodedKnownTokenRuns`
|
|
42
|
+
* (issue #2156), and the header is never logged by this module in any case.
|
|
43
|
+
*
|
|
44
|
+
* @see docs/case-studies/issue-2192/README.md
|
|
45
|
+
* @module git-auth-transport
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { ANONYMOUS_DOWNLOAD_LIMIT_PATTERNS, isAnonymousDownloadLimit } from './transient-errors.lib.mjs';
|
|
49
|
+
|
|
50
|
+
/** Hosts whose HTTPS git traffic is authenticated by default. */
|
|
51
|
+
export const DEFAULT_AUTHENTICATED_HOSTS = Object.freeze(['github.com']);
|
|
52
|
+
|
|
53
|
+
/** Env var used as the idempotency marker / diagnostic breadcrumb. */
|
|
54
|
+
export const GIT_AUTH_TRANSPORT_MARKER = 'HIVE_MIND_GIT_AUTH_TRANSPORT';
|
|
55
|
+
|
|
56
|
+
/** Env var an operator can set to opt out of forced authentication. */
|
|
57
|
+
export const GIT_AUTH_TRANSPORT_DISABLE = 'HIVE_MIND_DISABLE_GIT_AUTH_TRANSPORT';
|
|
58
|
+
|
|
59
|
+
// GitHub's wording when it refuses an *anonymous* git download lives in the
|
|
60
|
+
// shared transient vocabulary (`transient-errors.lib.mjs`) so classification
|
|
61
|
+
// and repair can never drift apart; re-exported for callers that only import
|
|
62
|
+
// this module.
|
|
63
|
+
export { ANONYMOUS_DOWNLOAD_LIMIT_PATTERNS, isAnonymousDownloadLimit };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the `Authorization` header value git should send to github.com.
|
|
67
|
+
*
|
|
68
|
+
* GitHub accepts the token as the *password* of HTTP Basic auth with any
|
|
69
|
+
* username; `x-access-token` is the username `actions/checkout` uses.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} token
|
|
72
|
+
* @returns {string} e.g. `Authorization: Basic eC1hY2Nlc3M...`
|
|
73
|
+
*/
|
|
74
|
+
export const buildAuthorizationHeader = token => `Authorization: Basic ${Buffer.from(`x-access-token:${token}`, 'utf8').toString('base64')}`;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read the number of `GIT_CONFIG_*` pairs already present in `env`.
|
|
78
|
+
*
|
|
79
|
+
* Anything unparseable is treated as zero rather than throwing: a malformed
|
|
80
|
+
* outer environment must not stop the solver from cloning.
|
|
81
|
+
*
|
|
82
|
+
* @param {Record<string, string|undefined>} env
|
|
83
|
+
* @returns {number}
|
|
84
|
+
*/
|
|
85
|
+
export const readGitConfigCount = env => {
|
|
86
|
+
const parsed = Number.parseInt(String(env?.GIT_CONFIG_COUNT ?? ''), 10);
|
|
87
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* True when `env` already carries an `extraheader` entry for every host.
|
|
92
|
+
*
|
|
93
|
+
* Recognises entries installed by this module *and* by an outer environment
|
|
94
|
+
* (a CI runner, a parent container), so authentication configured upstream is
|
|
95
|
+
* never duplicated.
|
|
96
|
+
*
|
|
97
|
+
* @param {Record<string, string|undefined>} env
|
|
98
|
+
* @param {string[]} [hosts]
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
export const hasGitAuthConfig = (env, hosts = DEFAULT_AUTHENTICATED_HOSTS) => {
|
|
102
|
+
const count = readGitConfigCount(env);
|
|
103
|
+
const configuredKeys = new Set();
|
|
104
|
+
for (let index = 0; index < count; index++) {
|
|
105
|
+
const key = env?.[`GIT_CONFIG_KEY_${index}`];
|
|
106
|
+
const value = env?.[`GIT_CONFIG_VALUE_${index}`];
|
|
107
|
+
if (typeof key === 'string' && typeof value === 'string' && value.trim()) configuredKeys.add(key.toLowerCase());
|
|
108
|
+
}
|
|
109
|
+
return hosts.every(host => configuredKeys.has(gitAuthConfigKey(host).toLowerCase()));
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* git config key that carries the preemptive `Authorization` header for `host`.
|
|
114
|
+
*
|
|
115
|
+
* The trailing slash matters: git matches `http.<url>.*` by URL prefix, so
|
|
116
|
+
* `https://github.com/` covers every repository on the host and nothing else.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} host
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
export const gitAuthConfigKey = host => `http.https://${host}/.extraheader`;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Compute the `GIT_CONFIG_*` variables that add preemptive authentication for
|
|
125
|
+
* `hosts`, preserving any entries already present in `env`.
|
|
126
|
+
*
|
|
127
|
+
* Pure: returns a patch, mutates nothing.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} params
|
|
130
|
+
* @param {string} params.token - GitHub token
|
|
131
|
+
* @param {string[]} [params.hosts]
|
|
132
|
+
* @param {Record<string, string|undefined>} [params.env] - environment to extend
|
|
133
|
+
* @returns {Record<string, string>} variables to merge into the environment
|
|
134
|
+
*/
|
|
135
|
+
export const buildGitAuthConfigEnv = ({ token, hosts = DEFAULT_AUTHENTICATED_HOSTS, env = {} }) => {
|
|
136
|
+
if (!token) throw new TypeError('buildGitAuthConfigEnv requires a GitHub token');
|
|
137
|
+
const header = buildAuthorizationHeader(token);
|
|
138
|
+
const patch = {};
|
|
139
|
+
let index = readGitConfigCount(env);
|
|
140
|
+
for (const host of hosts) {
|
|
141
|
+
patch[`GIT_CONFIG_KEY_${index}`] = gitAuthConfigKey(host);
|
|
142
|
+
patch[`GIT_CONFIG_VALUE_${index}`] = header;
|
|
143
|
+
index++;
|
|
144
|
+
}
|
|
145
|
+
patch.GIT_CONFIG_COUNT = String(index);
|
|
146
|
+
patch[GIT_AUTH_TRANSPORT_MARKER] = hosts.join(',');
|
|
147
|
+
return patch;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* True when the operator disabled forced authentication.
|
|
152
|
+
*
|
|
153
|
+
* @param {Record<string, string|undefined>} env
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
export const isGitAuthTransportDisabled = (env = process.env) => {
|
|
157
|
+
const raw = String(env?.[GIT_AUTH_TRANSPORT_DISABLE] ?? '')
|
|
158
|
+
.trim()
|
|
159
|
+
.toLowerCase();
|
|
160
|
+
return raw === '1' || raw === 'true' || raw === 'yes';
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolve a GitHub token without ever printing it.
|
|
165
|
+
*
|
|
166
|
+
* Order: explicit env vars first (an operator override beats stored state),
|
|
167
|
+
* then `gh auth token`. Returns `{ token, source }` or `{ token: null }`.
|
|
168
|
+
*
|
|
169
|
+
* @param {object} params
|
|
170
|
+
* @param {Function} params.$ - command-stream `$` tag
|
|
171
|
+
* @param {Record<string, string|undefined>} [params.env]
|
|
172
|
+
* @returns {Promise<{token: string|null, source: string|null, error: string|null}>}
|
|
173
|
+
*/
|
|
174
|
+
/**
|
|
175
|
+
* Return the caller's `$` or load command-stream's on demand.
|
|
176
|
+
*
|
|
177
|
+
* @param {Function|undefined} provided
|
|
178
|
+
* @returns {Promise<Function>} command-stream `$` tag
|
|
179
|
+
*/
|
|
180
|
+
export const resolveDollar = async provided => {
|
|
181
|
+
if (typeof provided === 'function') return provided;
|
|
182
|
+
// Call sites deep in the retry helpers do not carry a `$` of their own; fall
|
|
183
|
+
// back to the same command-stream instance the rest of the codebase loads.
|
|
184
|
+
const { ensureUseM } = await import('./use-m-bootstrap.lib.mjs');
|
|
185
|
+
const use = globalThis.use || (await ensureUseM());
|
|
186
|
+
return (await use('command-stream')).$;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const resolveGitHubToken = async ({ $, env = process.env }) => {
|
|
190
|
+
for (const name of ['GH_TOKEN', 'GITHUB_TOKEN']) {
|
|
191
|
+
const value = env?.[name];
|
|
192
|
+
if (typeof value === 'string' && value.trim()) return { token: value.trim(), source: name, error: null };
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
// `quietProbe` keeps the token out of the mirrored output that becomes the
|
|
196
|
+
// attached log (issue #2130); the value is only ever used in memory.
|
|
197
|
+
const { quietProbe } = await import('./quiet-probe.lib.mjs');
|
|
198
|
+
const result = await quietProbe(await resolveDollar($))`gh auth token`;
|
|
199
|
+
const token = (result?.stdout?.toString() || '').trim();
|
|
200
|
+
if (result?.code === 0 && token) return { token, source: 'gh auth token', error: null };
|
|
201
|
+
return { token: null, source: null, error: (result?.stderr?.toString() || '').trim() || `gh auth token exited ${result?.code}` };
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return { token: null, source: null, error: error?.message || String(error) };
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Ensure git sends credentials to github.com preemptively.
|
|
209
|
+
*
|
|
210
|
+
* Idempotent, never throws, and safe to call from any entry point: the worst
|
|
211
|
+
* case (no token, gh not installed, operator opt-out) leaves the environment
|
|
212
|
+
* exactly as it was and reports why.
|
|
213
|
+
*
|
|
214
|
+
* @param {object} params
|
|
215
|
+
* @param {Function} [params.$] - command-stream `$` tag (loaded on demand when omitted)
|
|
216
|
+
* @param {Function} [params.log] - logger; called with human-readable lines
|
|
217
|
+
* @param {Record<string, string|undefined>} [params.env] - environment to mutate (default `process.env`)
|
|
218
|
+
* @param {string[]} [params.hosts]
|
|
219
|
+
* @param {string} [params.reason] - short phrase explaining why it was invoked
|
|
220
|
+
* @param {boolean} [params.repair] - when true, try `gh-setup-git-identity --repair` if no token is found
|
|
221
|
+
* @returns {Promise<{status: 'applied'|'already-configured'|'disabled'|'no-token', hosts: string[], source?: string|null, error?: string|null}>}
|
|
222
|
+
*/
|
|
223
|
+
export const ensureAuthenticatedGitTransport = async ({ $, log = async () => {}, env = process.env, hosts = DEFAULT_AUTHENTICATED_HOSTS, reason = '', repair = false } = {}) => {
|
|
224
|
+
if (isGitAuthTransportDisabled(env)) {
|
|
225
|
+
await log(`ℹ️ Authenticated git transport disabled via ${GIT_AUTH_TRANSPORT_DISABLE}; git will download from github.com anonymously`, { verbose: true });
|
|
226
|
+
return { status: 'disabled', hosts };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (hasGitAuthConfig(env, hosts)) {
|
|
230
|
+
// Issue #2192 asks for enough diagnostics to reconstruct the transport state
|
|
231
|
+
// from a log. Verbose-only: on the happy path this fires before every clone.
|
|
232
|
+
await log(`🔐 Authenticated git transport already configured for ${hosts.join(', ')}${reason ? ` - ${reason}` : ''}`, { verbose: true });
|
|
233
|
+
return { status: 'already-configured', hosts };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let { token, source, error } = await resolveGitHubToken({ $, env });
|
|
237
|
+
|
|
238
|
+
if (!token && repair) {
|
|
239
|
+
// Issue #2192 asks for auto-recovery of the git/gh state when it is
|
|
240
|
+
// repairable without interactive credentials. `gh-setup-git-identity`
|
|
241
|
+
// derives the identity and the credential helper from the authenticated
|
|
242
|
+
// gh account; it cannot invent a token, so this only helps when gh state
|
|
243
|
+
// is broken rather than logged out.
|
|
244
|
+
const { repairGitIdentity } = await import('./git.lib.mjs');
|
|
245
|
+
const repaired = await repairGitIdentity();
|
|
246
|
+
await log(repaired.success ? '🔧 Repaired git/gh configuration with gh-setup-git-identity --repair' : `ℹ️ gh-setup-git-identity repair unavailable: ${repaired.error}`, { verbose: !repaired.success });
|
|
247
|
+
if (repaired.success) ({ token, source, error } = await resolveGitHubToken({ $, env }));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!token) {
|
|
251
|
+
await log(`⚠️ No GitHub token available for git transport${error ? ` (${error.split('\n')[0]})` : ''} - downloads from ${hosts.join(', ')} stay anonymous and can be throttled`, { level: 'warning' });
|
|
252
|
+
return { status: 'no-token', hosts, error };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
Object.assign(env, buildGitAuthConfigEnv({ token, hosts, env }));
|
|
256
|
+
await log(`🔐 Authenticated git transport enabled for ${hosts.join(', ')} (token source: ${source})${reason ? ` - ${reason}` : ''}`, { verbose: true });
|
|
257
|
+
return { status: 'applied', hosts, source };
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Point git at `gh` for credentials when nothing is configured yet.
|
|
262
|
+
*
|
|
263
|
+
* This is the *401* half of authentication (private repositories, pushes); the
|
|
264
|
+
* `extraheader` above is the *200* half (public downloads). Both are needed:
|
|
265
|
+
* neither can replace the other.
|
|
266
|
+
*
|
|
267
|
+
* @param {object} params
|
|
268
|
+
* @param {Function} params.$ - command-stream `$` tag
|
|
269
|
+
* @param {Function} [params.log]
|
|
270
|
+
* @param {string} [params.host]
|
|
271
|
+
* @returns {Promise<{status: 'present'|'configured'|'failed', error?: string}>}
|
|
272
|
+
*/
|
|
273
|
+
export const ensureGlobalCredentialHelper = async ({ $, log = async () => {}, host = 'github.com' }) => {
|
|
274
|
+
const { quietProbe } = await import('./quiet-probe.lib.mjs');
|
|
275
|
+
const dollar = await resolveDollar($);
|
|
276
|
+
const existing = await quietProbe(dollar)`git config --get-all ${`credential.https://${host}.helper`}`;
|
|
277
|
+
const configured = (existing?.stdout?.toString() || '')
|
|
278
|
+
.split('\n')
|
|
279
|
+
.map(line => line.trim())
|
|
280
|
+
.filter(Boolean);
|
|
281
|
+
if (configured.length > 0) return { status: 'present' };
|
|
282
|
+
|
|
283
|
+
const setup = await quietProbe(dollar)`gh auth setup-git 2>&1`;
|
|
284
|
+
if (setup?.code === 0) {
|
|
285
|
+
await log(`🔑 Configured the gh credential helper for ${host}`, { verbose: true });
|
|
286
|
+
return { status: 'configured' };
|
|
287
|
+
}
|
|
288
|
+
const reason = (setup?.stdout?.toString() || setup?.stderr?.toString() || '').trim().split('\n')[0];
|
|
289
|
+
await log(`ℹ️ Could not configure a global git credential helper for ${host}${reason ? `: ${reason}` : ''}`, { verbose: true });
|
|
290
|
+
return { status: 'failed', error: reason };
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
export default {
|
|
294
|
+
ANONYMOUS_DOWNLOAD_LIMIT_PATTERNS,
|
|
295
|
+
DEFAULT_AUTHENTICATED_HOSTS,
|
|
296
|
+
GIT_AUTH_TRANSPORT_DISABLE,
|
|
297
|
+
GIT_AUTH_TRANSPORT_MARKER,
|
|
298
|
+
buildAuthorizationHeader,
|
|
299
|
+
buildGitAuthConfigEnv,
|
|
300
|
+
ensureAuthenticatedGitTransport,
|
|
301
|
+
ensureGlobalCredentialHelper,
|
|
302
|
+
gitAuthConfigKey,
|
|
303
|
+
hasGitAuthConfig,
|
|
304
|
+
resolveDollar,
|
|
305
|
+
isAnonymousDownloadLimit,
|
|
306
|
+
isGitAuthTransportDisabled,
|
|
307
|
+
readGitConfigCount,
|
|
308
|
+
resolveGitHubToken,
|
|
309
|
+
};
|
package/src/github-merge.lib.mjs
CHANGED
|
@@ -21,6 +21,9 @@ import { githubLimits } from './config.lib.mjs';
|
|
|
21
21
|
import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
|
|
22
22
|
import { getTerminalGitHubEntityErrorMessage, isTerminalGitHubEntityError } from './github-terminal-state.lib.mjs';
|
|
23
23
|
import { cancellableSleep } from './interruptible-sleep.lib.mjs';
|
|
24
|
+
// Issue #2182: draft detection and merge-failure classification live in one
|
|
25
|
+
// pure module shared by every merge call site.
|
|
26
|
+
import { classifyMergeError, evaluatePullRequestMergeability } from './merge-error-classification.lib.mjs';
|
|
24
27
|
|
|
25
28
|
// Issue #1722: gh api `--paginate --slurp` responses for repos with many
|
|
26
29
|
// historical workflow runs can easily exceed Node's default 1 MB exec buffer
|
|
@@ -466,7 +469,12 @@ export async function checkPRMergeable(owner, repo, prNumber, verbose = false, o
|
|
|
466
469
|
for (let attempt = 0; attempt < MAX_UNKNOWN_RETRIES; attempt++) {
|
|
467
470
|
if (isCancelled?.()) return { mergeable: false, reason: 'Operation was cancelled', cancelled: true };
|
|
468
471
|
try {
|
|
469
|
-
|
|
472
|
+
// Issue #2182: `isDraft` MUST be part of this query. GitHub answers
|
|
473
|
+
// mergeable=MERGEABLE / mergeStateStatus=CLEAN for a draft pull request
|
|
474
|
+
// with no other blockers, so without this field a draft PR was declared
|
|
475
|
+
// mergeable and `gh pr merge` failed forever with
|
|
476
|
+
// "Pull Request is still a draft".
|
|
477
|
+
const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json isDraft,mergeable,mergeStateStatus`);
|
|
470
478
|
const pr = JSON.parse(stdout.trim());
|
|
471
479
|
|
|
472
480
|
// Issue #1339: If mergeStateStatus is 'UNKNOWN', GitHub is still computing.
|
|
@@ -486,36 +494,16 @@ export async function checkPRMergeable(owner, repo, prNumber, verbose = false, o
|
|
|
486
494
|
return { mergeable: false, mergeableState: pr.mergeable, mergeStateStatus: pr.mergeStateStatus, reason: `Merge state: UNKNOWN (GitHub could not compute mergeability after ${MAX_UNKNOWN_RETRIES} attempts)` };
|
|
487
495
|
}
|
|
488
496
|
|
|
489
|
-
const
|
|
490
|
-
let reason = null;
|
|
491
|
-
|
|
492
|
-
if (!mergeable) {
|
|
493
|
-
switch (pr.mergeStateStatus) {
|
|
494
|
-
case 'BLOCKED':
|
|
495
|
-
reason = 'PR is blocked (possibly by branch protection rules)';
|
|
496
|
-
break;
|
|
497
|
-
case 'BEHIND':
|
|
498
|
-
reason = 'PR branch is behind the base branch';
|
|
499
|
-
break;
|
|
500
|
-
case 'DIRTY':
|
|
501
|
-
reason = 'PR has merge conflicts';
|
|
502
|
-
break;
|
|
503
|
-
case 'UNSTABLE':
|
|
504
|
-
reason = 'PR has failing required status checks';
|
|
505
|
-
break;
|
|
506
|
-
case 'DRAFT':
|
|
507
|
-
reason = 'PR is a draft';
|
|
508
|
-
break;
|
|
509
|
-
default:
|
|
510
|
-
reason = `Merge state: ${pr.mergeStateStatus || 'unknown'}`;
|
|
511
|
-
}
|
|
512
|
-
}
|
|
497
|
+
const evaluation = evaluatePullRequestMergeability(pr);
|
|
513
498
|
|
|
514
499
|
if (verbose) {
|
|
515
|
-
|
|
500
|
+
// Issue #2182: isDraft is logged explicitly. In the reported 4.5-day run
|
|
501
|
+
// the log only ever showed "mergeable: true, state: CLEAN", which hid the
|
|
502
|
+
// actual blocker.
|
|
503
|
+
console.log(`[VERBOSE] /merge: PR #${prNumber} mergeable: ${evaluation.mergeable}, state: ${pr.mergeStateStatus}, isDraft: ${pr.isDraft === true}`);
|
|
516
504
|
}
|
|
517
505
|
|
|
518
|
-
return { mergeable, mergeableState:
|
|
506
|
+
return { mergeable: evaluation.mergeable, isDraft: evaluation.isDraft, mergeableState: evaluation.mergeableState, mergeStateStatus: evaluation.mergeStateStatus, reason: evaluation.reason };
|
|
519
507
|
} catch (error) {
|
|
520
508
|
if (isTerminalGitHubEntityError(error)) {
|
|
521
509
|
const terminalError = getTerminalGitHubEntityErrorMessage(error);
|
|
@@ -607,13 +595,21 @@ export async function mergePullRequest(owner, repo, prNumber, options = {}, verb
|
|
|
607
595
|
|
|
608
596
|
return { success: true, error: null };
|
|
609
597
|
} catch (error) {
|
|
598
|
+
// Issue #2182: classify the failure so watch loops can stop (or self-heal)
|
|
599
|
+
// instead of retrying an impossible merge every 120 seconds forever.
|
|
600
|
+
const classification = classifyMergeError(error.message);
|
|
610
601
|
if (verbose) {
|
|
611
602
|
console.log(`[VERBOSE] /merge: Failed to merge PR #${prNumber}: ${error.message}`);
|
|
603
|
+
console.log(`[VERBOSE] /merge: Failure category: ${classification.category} (terminal=${classification.terminal}, recoverable=${classification.recoverable})`);
|
|
612
604
|
}
|
|
613
|
-
return { success: false, error: error.message };
|
|
605
|
+
return { success: false, error: error.message, category: classification.category, terminal: classification.terminal, recoverable: classification.recoverable, resolution: classification.resolution };
|
|
614
606
|
}
|
|
615
607
|
}
|
|
616
608
|
|
|
609
|
+
// Issue #2182: re-exported so merge call sites can import classification from
|
|
610
|
+
// the same module they already use for merging.
|
|
611
|
+
export { classifyMergeError, evaluatePullRequestMergeability, MERGE_ERROR_CATEGORIES, MAX_CONSECUTIVE_MERGE_FAILURES } from './merge-error-classification.lib.mjs';
|
|
612
|
+
|
|
617
613
|
/**
|
|
618
614
|
* Parse and validate a repository URL for the merge command
|
|
619
615
|
* @param {string} url - Repository URL
|
package/src/lib.mjs
CHANGED
|
@@ -710,6 +710,13 @@ export const gitCmdRetry = async (cmdFn, options = {}) => {
|
|
|
710
710
|
const description = describeTransientError({ message: combinedOutput });
|
|
711
711
|
|
|
712
712
|
if (description.transient && attempt < maxAttempts) {
|
|
713
|
+
// Issue #2192: GitHub rejected the request because it arrived
|
|
714
|
+
// *unauthenticated*. Waiting does not change that, so authenticate the
|
|
715
|
+
// transport (repairing the gh state if needed) before the next attempt.
|
|
716
|
+
if (description.category === 'github-anonymous-rate-limit') {
|
|
717
|
+
const { ensureAuthenticatedGitTransport } = await import('./git-auth-transport.lib.mjs');
|
|
718
|
+
await ensureAuthenticatedGitTransport({ log: logFn, repair: true, reason: `${label} rejected as unauthenticated` });
|
|
719
|
+
}
|
|
713
720
|
const waitTime = delay * Math.pow(backoff, attempt - 1);
|
|
714
721
|
await logFn(`⚠️ ${label}: transient git error (attempt ${attempt}/${maxAttempts}), retrying in ${Math.round(waitTime / 1000)}s... [${formatTransientDiagnostics(description)}]`, { level: 'warn' });
|
|
715
722
|
await sleep(waitTime);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Classification helpers for pull request mergeability and merge failures.
|
|
5
|
+
*
|
|
6
|
+
* Issue #2182: `/solve ... --auto-merge --auto-restart-until-mergeable` kept a
|
|
7
|
+
* single task "processing" for 4d 12h 13m. The pull request had been converted
|
|
8
|
+
* to draft by an auto-restart iteration and never converted back, so:
|
|
9
|
+
*
|
|
10
|
+
* 1. `checkPRMergeable()` asked GitHub only for `mergeable,mergeStateStatus`.
|
|
11
|
+
* A draft pull request with no other blockers answers
|
|
12
|
+
* `MERGEABLE` / `CLEAN` — the `case 'DRAFT'` branch that was supposed to
|
|
13
|
+
* catch this was dead code, because it is only reachable when
|
|
14
|
+
* `mergeable !== 'MERGEABLE'`. The watch loop therefore declared
|
|
15
|
+
* "PR IS MERGEABLE!" on every check.
|
|
16
|
+
* 2. `gh pr merge` then failed with
|
|
17
|
+
* `GraphQL: Pull Request is still a draft (mergePullRequest)`.
|
|
18
|
+
* 3. The failure was logged as "Will continue monitoring..." and retried
|
|
19
|
+
* every 120 seconds, forever (5384 identical failures in the reported run).
|
|
20
|
+
*
|
|
21
|
+
* These two pure functions are the single place where "is this pull request
|
|
22
|
+
* actually mergeable?" and "is this merge failure worth retrying?" are decided,
|
|
23
|
+
* so every merge call site can share the same answer.
|
|
24
|
+
*
|
|
25
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2182
|
|
26
|
+
* @see docs/case-studies/issue-2182/README.md for the full timeline and evidence
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Merge failure categories.
|
|
31
|
+
*
|
|
32
|
+
* `recoverable` means hive-mind itself can fix the cause and retry (currently
|
|
33
|
+
* only the draft state). `terminal` means retrying the exact same merge cannot
|
|
34
|
+
* succeed without a human or a new AI session, so a watch loop must stop
|
|
35
|
+
* instead of hammering the API.
|
|
36
|
+
*/
|
|
37
|
+
export const MERGE_ERROR_CATEGORIES = {
|
|
38
|
+
DRAFT: 'draft',
|
|
39
|
+
CONFLICT: 'conflict',
|
|
40
|
+
BLOCKED: 'blocked',
|
|
41
|
+
CLOSED: 'closed',
|
|
42
|
+
PERMISSION: 'permission',
|
|
43
|
+
NOT_MERGEABLE: 'not_mergeable',
|
|
44
|
+
UNKNOWN: 'unknown',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How many consecutive failed merge attempts a watch loop may make before it
|
|
49
|
+
* gives up and reports the stop. Issue #2182: there was no such ceiling.
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_CONSECUTIVE_MERGE_FAILURES = 3;
|
|
52
|
+
|
|
53
|
+
const MERGE_ERROR_PATTERNS = [
|
|
54
|
+
// "GraphQL: Pull Request is still a draft (mergePullRequest)"
|
|
55
|
+
{ category: MERGE_ERROR_CATEGORIES.DRAFT, terminal: false, recoverable: true, pattern: /still a draft|is a draft|draft state|convert(ed)? to draft/i, resolution: 'Mark the pull request as ready for review (gh pr ready <number>) before merging.' },
|
|
56
|
+
{ category: MERGE_ERROR_CATEGORIES.CLOSED, terminal: true, recoverable: false, pattern: /pull request is closed|already merged|has already been merged|not open/i, resolution: 'The pull request is no longer open — nothing left to merge.' },
|
|
57
|
+
{ category: MERGE_ERROR_CATEGORIES.PERMISSION, terminal: true, recoverable: false, pattern: /resource not accessible|must have (admin|write|push)|permission|403|not authorized|forbidden/i, resolution: 'Grant the token merge permission on the repository, or merge manually.' },
|
|
58
|
+
{ category: MERGE_ERROR_CATEGORIES.BLOCKED, terminal: true, recoverable: false, pattern: /required status check|approving review|review is required|protected branch|branch protection|changes must be made through a pull request|merge queue/i, resolution: 'Satisfy the branch protection requirements (reviews / required checks) or merge manually.' },
|
|
59
|
+
{ category: MERGE_ERROR_CATEGORIES.CONFLICT, terminal: false, recoverable: false, pattern: /merge conflict|not mergeable due to conflicts|conflicts? with the base branch/i, resolution: 'Resolve the merge conflicts with the base branch, then retry.' },
|
|
60
|
+
{ category: MERGE_ERROR_CATEGORIES.NOT_MERGEABLE, terminal: false, recoverable: false, pattern: /pull request is not mergeable|is not mergeable|base branch was modified/i, resolution: 'Wait for GitHub to recompute mergeability, or update the branch from the base branch.' },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Classify a `gh pr merge` failure message.
|
|
65
|
+
*
|
|
66
|
+
* @param {string|null|undefined} errorMessage raw stderr/message from `gh pr merge`
|
|
67
|
+
* @returns {{category: string, terminal: boolean, recoverable: boolean, resolution: string|null}}
|
|
68
|
+
*/
|
|
69
|
+
export const classifyMergeError = errorMessage => {
|
|
70
|
+
const text = typeof errorMessage === 'string' ? errorMessage : '';
|
|
71
|
+
for (const entry of MERGE_ERROR_PATTERNS) {
|
|
72
|
+
if (entry.pattern.test(text)) {
|
|
73
|
+
return { category: entry.category, terminal: entry.terminal, recoverable: entry.recoverable, resolution: entry.resolution };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { category: MERGE_ERROR_CATEGORIES.UNKNOWN, terminal: false, recoverable: false, resolution: null };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decide whether a pull request payload describes a mergeable pull request.
|
|
81
|
+
*
|
|
82
|
+
* Expects the parsed output of
|
|
83
|
+
* `gh pr view <n> --json isDraft,mergeable,mergeStateStatus`.
|
|
84
|
+
*
|
|
85
|
+
* Issue #2182: `isDraft` is checked FIRST and independently of
|
|
86
|
+
* `mergeStateStatus`, because GitHub reports `CLEAN`/`MERGEABLE` for a draft
|
|
87
|
+
* pull request that has no other blockers, while `gh pr merge` still refuses it.
|
|
88
|
+
*
|
|
89
|
+
* @param {{isDraft?: boolean, mergeable?: string|null, mergeStateStatus?: string|null}} pr
|
|
90
|
+
* @returns {{mergeable: boolean, isDraft: boolean, mergeableState: string|null, mergeStateStatus: string|null, reason: string|null}}
|
|
91
|
+
*/
|
|
92
|
+
export const evaluatePullRequestMergeability = (pr = {}) => {
|
|
93
|
+
const isDraft = pr.isDraft === true;
|
|
94
|
+
const mergeableState = pr.mergeable ?? null;
|
|
95
|
+
const mergeStateStatus = pr.mergeStateStatus ?? null;
|
|
96
|
+
|
|
97
|
+
if (isDraft) {
|
|
98
|
+
return { mergeable: false, isDraft: true, mergeableState, mergeStateStatus, reason: 'PR is a draft' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (mergeableState === 'MERGEABLE') {
|
|
102
|
+
return { mergeable: true, isDraft: false, mergeableState, mergeStateStatus, reason: null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let reason;
|
|
106
|
+
switch (mergeStateStatus) {
|
|
107
|
+
case 'BLOCKED':
|
|
108
|
+
reason = 'PR is blocked (possibly by branch protection rules)';
|
|
109
|
+
break;
|
|
110
|
+
case 'BEHIND':
|
|
111
|
+
reason = 'PR branch is behind the base branch';
|
|
112
|
+
break;
|
|
113
|
+
case 'DIRTY':
|
|
114
|
+
reason = 'PR has merge conflicts';
|
|
115
|
+
break;
|
|
116
|
+
case 'UNSTABLE':
|
|
117
|
+
reason = 'PR has failing required status checks';
|
|
118
|
+
break;
|
|
119
|
+
case 'DRAFT':
|
|
120
|
+
reason = 'PR is a draft';
|
|
121
|
+
break;
|
|
122
|
+
default:
|
|
123
|
+
reason = `Merge state: ${mergeStateStatus || 'unknown'}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { mergeable: false, isDraft: false, mergeableState, mergeStateStatus, reason };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export default {
|
|
130
|
+
MERGE_ERROR_CATEGORIES,
|
|
131
|
+
MAX_CONSECUTIVE_MERGE_FAILURES,
|
|
132
|
+
classifyMergeError,
|
|
133
|
+
evaluatePullRequestMergeability,
|
|
134
|
+
};
|
|
@@ -227,6 +227,7 @@ const KNOWN_OPTION_NAMES = [
|
|
|
227
227
|
'allow-to-push-to-contributors-pull-requests-as-maintainer',
|
|
228
228
|
'prefix-fork-name-with-owner-name',
|
|
229
229
|
'auto-restart-max-iterations',
|
|
230
|
+
'auto-restart-until-mergeable-timeout-hours',
|
|
230
231
|
'auto-resume-max-iterations',
|
|
231
232
|
'auto-continue-only-on-new-comments',
|
|
232
233
|
'auto-restart-on-limit-reset',
|