@link-assistant/hive-mind 2.11.0 → 2.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/fix.ci-cd-issue.lib.mjs +18 -5
- package/src/fix.ci-cd.lib.mjs +79 -8
- package/src/fix.mjs +1 -1
- package/src/pr-draft-state.lib.mjs +137 -0
- package/src/solve.auto-continue.lib.mjs +8 -0
- package/src/solve.restart-shared.lib.mjs +19 -0
- package/src/solve.session.lib.mjs +43 -60
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.11.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 2777cf5: Stop `/task --ci-cd` and `/fix --ci-cd` from listing the same workflow many times in the generated issue (issue #2125). When the latest default-branch commit has no workflow runs — typical for release commits — the collector falls back to the recent runs of the default branch, which span many commits; every one of them became a table row, so `link-assistant/agent#287` listed two workflows twenty times and reported "20 (9 not passing)". `dedupeRunsByWorkflow()` now keeps only the most recent run of each workflow (by `workflow_id`, then `path`/`name`, resolved with `created_at`/`run_attempt`/`id`), the failure summary counts the same deduplicated set, and the branch-fallback table gained a Commit column because its rows can come from different commits. The fallback fetches 100 runs instead of 20 so a rarely-run workflow still appears after collapsing, and `prepareCiCdIssue()` logs how many runs it collapsed.
|
|
8
|
+
|
|
9
|
+
## 2.11.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- aea5fe1: Put the pull request back into draft whenever a working session starts, restarts or resumes (issue #2123). Draft/ready transitions now live in one shared module (`src/pr-draft-state.lib.mjs`) that is called from `startWorkSession()` for every continue-mode session — the previous `--watch`/`--auto-continue` gate is gone — and from `executeToolIteration()`, which covers watch mode, temporary auto-restart on uncommitted changes, auto-restart-until-mergeable, escalate, keep-working and auto-ensure-requirements. Limit-reset auto-resume/auto-restart now also forwards `--auto-continue` so the resumed process re-attaches to the existing PR instead of running detached from it. The helper is a no-op for PRs that are already in the target state, merged or closed, and logs the observed `isDraft`/`state` under `--verbose`.
|
|
14
|
+
|
|
3
15
|
## 2.11.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { spawn } from 'child_process';
|
|
7
|
-
import { CI_CD_ISSUE_LABELS, CI_CD_ISSUE_TYPE, buildCiCdIssueBody, buildCiCdIssueTitle } from './fix.ci-cd.lib.mjs';
|
|
7
|
+
import { CI_CD_ISSUE_LABELS, CI_CD_ISSUE_TYPE, buildCiCdIssueBody, buildCiCdIssueTitle, dedupeRunsByWorkflow } from './fix.ci-cd.lib.mjs';
|
|
8
8
|
import { createTaskIssue } from './task.issue-creation.lib.mjs';
|
|
9
9
|
|
|
10
10
|
function runCommand(command, args, options = {}) {
|
|
@@ -70,7 +70,9 @@ async function getLatestCommit(repository, branch, run, warn) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
// `workflow_id`, `created_at` and `run_attempt` are what let
|
|
74
|
+
// `dedupeRunsByWorkflow` keep the latest run of each workflow (issue #2125).
|
|
75
|
+
const RUNS_JQ = '[.workflow_runs[] | {id: .id, name: .name, workflow_id: .workflow_id, path: .path, status: .status, conclusion: .conclusion, html_url: .html_url, head_sha: .head_sha, created_at: .created_at, run_attempt: .run_attempt}]';
|
|
74
76
|
|
|
75
77
|
async function getRunsForCommit(repository, sha, run, warn) {
|
|
76
78
|
if (!sha) return [];
|
|
@@ -87,7 +89,7 @@ async function getRunsForCommit(repository, sha, run, warn) {
|
|
|
87
89
|
async function getRecentBranchRuns(repository, branch, run, warn) {
|
|
88
90
|
if (!branch) return [];
|
|
89
91
|
try {
|
|
90
|
-
const json = await commandOutput(run, 'gh', ['api', `repos/${repository.fullName}/actions/runs?branch=${encodeURIComponent(branch)}&per_page=
|
|
92
|
+
const json = await commandOutput(run, 'gh', ['api', `repos/${repository.fullName}/actions/runs?branch=${encodeURIComponent(branch)}&per_page=100`, '--jq', RUNS_JQ]);
|
|
91
93
|
const parsed = JSON.parse(json);
|
|
92
94
|
return Array.isArray(parsed) ? parsed : [];
|
|
93
95
|
} catch (error) {
|
|
@@ -96,7 +98,7 @@ async function getRecentBranchRuns(repository, branch, run, warn) {
|
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
export async function prepareCiCdIssue({ repository, run = runCommand, warn = message => console.warn(message) }) {
|
|
101
|
+
export async function prepareCiCdIssue({ repository, run = runCommand, warn = message => console.warn(message), log = null }) {
|
|
100
102
|
const [languages, defaultBranch] = await Promise.all([detectLanguages(repository, run, warn), getDefaultBranch(repository, run, warn)]);
|
|
101
103
|
const commit = await getLatestCommit(repository, defaultBranch, run, warn);
|
|
102
104
|
let runs = await getRunsForCommit(repository, commit?.sha, run, warn);
|
|
@@ -110,11 +112,22 @@ export async function prepareCiCdIssue({ repository, run = runCommand, warn = me
|
|
|
110
112
|
}
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
// The branch fallback returns every run of every workflow across many
|
|
116
|
+
// commits; the issue must list one row per workflow (issue #2125).
|
|
117
|
+
const fetchedRuns = runs.length;
|
|
118
|
+
runs = dedupeRunsByWorkflow(runs);
|
|
119
|
+
const duplicates = fetchedRuns - runs.length;
|
|
120
|
+
if (duplicates > 0 && typeof log === 'function') {
|
|
121
|
+
log(`ℹ️ Collapsed ${duplicates} older CI/CD run(s) — keeping the latest run of each workflow (${runs.length} workflow(s), source: ${runsSource}).`);
|
|
122
|
+
}
|
|
123
|
+
|
|
113
124
|
return {
|
|
114
125
|
repository,
|
|
115
126
|
defaultBranch,
|
|
116
127
|
commit,
|
|
117
128
|
runs,
|
|
129
|
+
fetchedRuns,
|
|
130
|
+
duplicateRuns: duplicates,
|
|
118
131
|
languages,
|
|
119
132
|
runsSource,
|
|
120
133
|
title: buildCiCdIssueTitle(),
|
|
@@ -123,7 +136,7 @@ export async function prepareCiCdIssue({ repository, run = runCommand, warn = me
|
|
|
123
136
|
}
|
|
124
137
|
|
|
125
138
|
export async function createCiCdIssue({ repository, prepared = null, run = runCommand, log = null, warn = message => console.warn(message) }) {
|
|
126
|
-
const issueDraft = prepared || (await prepareCiCdIssue({ repository, run, warn }));
|
|
139
|
+
const issueDraft = prepared || (await prepareCiCdIssue({ repository, run, warn, log }));
|
|
127
140
|
const issue = await createTaskIssue({
|
|
128
141
|
repository,
|
|
129
142
|
title: issueDraft.title,
|
package/src/fix.ci-cd.lib.mjs
CHANGED
|
@@ -249,27 +249,95 @@ export function buildTemplatesSection(languages) {
|
|
|
249
249
|
return lines.join('\n');
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
/**
|
|
253
|
-
|
|
252
|
+
/**
|
|
253
|
+
* Stable identity of the workflow a run belongs to (issue #2125).
|
|
254
|
+
*
|
|
255
|
+
* `workflow_id` is the authoritative key: two workflow files may share the same
|
|
256
|
+
* display `name`, and one workflow file may be renamed between runs. The name
|
|
257
|
+
* (and `path`) are only fallbacks for payloads that omit the id.
|
|
258
|
+
*/
|
|
259
|
+
export function runWorkflowKey(run) {
|
|
260
|
+
const workflowId = run?.workflow_id ?? run?.workflowId;
|
|
261
|
+
if (workflowId !== undefined && workflowId !== null && workflowId !== '') return `id:${workflowId}`;
|
|
262
|
+
if (run?.path) return `path:${run.path}`;
|
|
263
|
+
const name = run?.name || run?.workflowName;
|
|
264
|
+
// A run with no identity at all cannot be proven to be a duplicate.
|
|
265
|
+
return name ? `name:${String(name).toLowerCase()}` : null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Recency of a run: newest first, using created_at, then attempt, then id. */
|
|
269
|
+
function compareRunRecency(a, b) {
|
|
270
|
+
const timeA = Date.parse(a?.created_at || a?.run_started_at || '') || 0;
|
|
271
|
+
const timeB = Date.parse(b?.created_at || b?.run_started_at || '') || 0;
|
|
272
|
+
if (timeA !== timeB) return timeB - timeA;
|
|
273
|
+
const attemptA = Number(a?.run_attempt) || 0;
|
|
274
|
+
const attemptB = Number(b?.run_attempt) || 0;
|
|
275
|
+
if (attemptA !== attemptB) return attemptB - attemptA;
|
|
276
|
+
return (Number(b?.id) || 0) - (Number(a?.id) || 0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Keep only the most recent run per workflow (issue #2125).
|
|
281
|
+
*
|
|
282
|
+
* When `/fix --ci-cd` falls back to "recent runs on the default branch" the
|
|
283
|
+
* GitHub API returns every run of every workflow across many commits, so the
|
|
284
|
+
* generated issue listed the same two workflows twenty times. One row per
|
|
285
|
+
* workflow — its latest run — is what makes the table actionable.
|
|
286
|
+
*
|
|
287
|
+
* Order of the surviving rows follows the input (the API returns newest first).
|
|
288
|
+
*/
|
|
289
|
+
export function dedupeRunsByWorkflow(runs) {
|
|
290
|
+
const list = Array.isArray(runs) ? runs : [];
|
|
291
|
+
const bestByWorkflow = new Map(); // key -> { run, index }
|
|
292
|
+
list.forEach((run, index) => {
|
|
293
|
+
const key = runWorkflowKey(run) ?? `index:${index}`;
|
|
294
|
+
const existing = bestByWorkflow.get(key);
|
|
295
|
+
if (!existing || compareRunRecency(run, existing.run) < 0) {
|
|
296
|
+
bestByWorkflow.set(key, { run, index: existing ? existing.index : index });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
return [...bestByWorkflow.values()].sort((a, b) => a.index - b.index).map(entry => entry.run);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** How many rows `dedupeRunsByWorkflow` would drop (for verbose logging). */
|
|
303
|
+
export function countDuplicateRuns(runs) {
|
|
254
304
|
const list = Array.isArray(runs) ? runs : [];
|
|
305
|
+
return list.length - dedupeRunsByWorkflow(list).length;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Render the CI/CD runs section from the GitHub Actions API payload.
|
|
310
|
+
*
|
|
311
|
+
* Runs are deduplicated per workflow (issue #2125). Pass `includeCommit: true`
|
|
312
|
+
* when the rows may come from different commits (the default-branch fallback)
|
|
313
|
+
* so it stays visible which commit each run belongs to.
|
|
314
|
+
*/
|
|
315
|
+
export function buildRunsSection(runs, { emptyMessage, includeCommit = false } = {}) {
|
|
316
|
+
const list = dedupeRunsByWorkflow(runs);
|
|
255
317
|
if (list.length === 0) {
|
|
256
318
|
return emptyMessage || 'No CI/CD runs were found for the latest default-branch commit.';
|
|
257
319
|
}
|
|
258
|
-
const header = '| Workflow | Status | Conclusion | Run |\n| --- | --- | --- | --- |';
|
|
320
|
+
const header = includeCommit ? '| Workflow | Status | Conclusion | Commit | Run |\n| --- | --- | --- | --- | --- |' : '| Workflow | Status | Conclusion | Run |\n| --- | --- | --- | --- |';
|
|
259
321
|
const rows = list.map(run => {
|
|
260
322
|
const name = run.name || run.workflowName || 'unknown';
|
|
261
323
|
const status = run.status || 'unknown';
|
|
262
324
|
const conclusion = run.conclusion || (status === 'completed' ? 'unknown' : 'in_progress');
|
|
263
325
|
const url = run.html_url || run.url || '';
|
|
264
326
|
const runLabel = url ? `[run](${url})` : '—';
|
|
265
|
-
return `| ${name} | ${status} | ${conclusion} | ${runLabel} |`;
|
|
327
|
+
if (!includeCommit) return `| ${name} | ${status} | ${conclusion} | ${runLabel} |`;
|
|
328
|
+
const sha = shortSha(run.head_sha);
|
|
329
|
+
return `| ${name} | ${status} | ${conclusion} | ${sha ? `\`${sha}\`` : '—'} | ${runLabel} |`;
|
|
266
330
|
});
|
|
267
331
|
return [header, ...rows].join('\n');
|
|
268
332
|
}
|
|
269
333
|
|
|
270
|
-
/**
|
|
334
|
+
/**
|
|
335
|
+
* Count the runs that did not pass (failure/cancelled/timed_out/etc.).
|
|
336
|
+
* Counts one run per workflow so the summary matches the rendered table
|
|
337
|
+
* (issue #2125).
|
|
338
|
+
*/
|
|
271
339
|
export function summarizeRunFailures(runs) {
|
|
272
|
-
const list =
|
|
340
|
+
const list = dedupeRunsByWorkflow(runs);
|
|
273
341
|
const passing = new Set(['success', 'neutral', 'skipped']);
|
|
274
342
|
const failing = list.filter(run => {
|
|
275
343
|
const conclusion = (run.conclusion || '').toLowerCase();
|
|
@@ -375,7 +443,10 @@ export const CI_CD_ISSUE_LABELS = Object.freeze(['bug']);
|
|
|
375
443
|
*/
|
|
376
444
|
export function buildCiCdIssueBody({ repository, defaultBranch, commit, runs, languages, runsSource = 'commit', omittedOptions = FIX_FORWARDED_SOLVE_OPTIONS }) {
|
|
377
445
|
const { sortedTemplates } = mapLanguagesToTemplates(languages);
|
|
378
|
-
|
|
446
|
+
// One row per workflow: the branch fallback returns every run of every
|
|
447
|
+
// workflow across many commits (issue #2125).
|
|
448
|
+
const uniqueRuns = dedupeRunsByWorkflow(runs);
|
|
449
|
+
const { total, failing } = summarizeRunFailures(uniqueRuns);
|
|
379
450
|
|
|
380
451
|
const commitLine = commit?.sha ? `\`${shortSha(commit.sha)}\`${commit.url ? ` ([commit](${commit.url}))` : ''}${commit.message ? ` — ${String(commit.message).split('\n')[0]}` : ''}` : 'unknown';
|
|
381
452
|
|
|
@@ -385,7 +456,7 @@ export function buildCiCdIssueBody({ repository, defaultBranch, commit, runs, la
|
|
|
385
456
|
const runsHeading = runsSource === 'branch' ? `Recent CI/CD runs on \`${defaultBranch || 'default branch'}\`` : 'Latest default-branch CI/CD runs';
|
|
386
457
|
const runsEmptyMessage = runsSource === 'branch' ? `No recent CI/CD runs were found on \`${defaultBranch || 'the default branch'}\`.` : 'No CI/CD runs were found for the latest default-branch commit.';
|
|
387
458
|
|
|
388
|
-
const sections = [`### ${runsHeading}`, '', buildRunsSection(
|
|
459
|
+
const sections = [`### ${runsHeading}`, '', buildRunsSection(uniqueRuns, { emptyMessage: runsEmptyMessage, includeCommit: runsSource === 'branch' }), '', buildStandardPrompt({ templatesSorted: sortedTemplates, omittedOptions }), '', '---', '', '<details>', '<summary>Context collected by <code>/fix --ci-cd</code></summary>', '', `- **Repository:** [${repository?.fullName}](${repository?.url})`, `- **Default branch:** \`${defaultBranch || 'unknown'}\``, `- **Latest commit:** ${commitLine}`, `- **CI/CD runs found:** ${total} (${failing} not passing)`, '', '**Detected languages**', '', buildLanguagesSection(languages), '', '**Recommended CI/CD templates**', '', buildTemplatesSection(languages), '', '</details>'];
|
|
389
460
|
|
|
390
461
|
return sections.join('\n');
|
|
391
462
|
}
|
package/src/fix.mjs
CHANGED
|
@@ -83,7 +83,7 @@ async function main() {
|
|
|
83
83
|
const repository = parsed.repository;
|
|
84
84
|
console.log(`🔧 /fix --ci-cd for ${repository.fullName}`);
|
|
85
85
|
|
|
86
|
-
const prepared = await prepareCiCdIssue({ repository });
|
|
86
|
+
const prepared = await prepareCiCdIssue({ repository, log: message => console.log(` ${message}`) });
|
|
87
87
|
const { defaultBranch, commit, runs, runsSource, title, body } = prepared;
|
|
88
88
|
|
|
89
89
|
const { total, failing } = summarizeRunFailures(runs);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for pull request draft/ready state transitions.
|
|
3
|
+
*
|
|
4
|
+
* Issue #2123: every place that starts, restarts or resumes a working session must
|
|
5
|
+
* put the pull request back into draft mode (if it is not already a draft), and every
|
|
6
|
+
* place that ends a working session must convert it back to ready for review.
|
|
7
|
+
*
|
|
8
|
+
* Before this module the logic was duplicated inline in solve.session.lib.mjs and was
|
|
9
|
+
* gated behind `argv.watch || argv.autoContinue`, so auto-restart / auto-resume
|
|
10
|
+
* sessions (temporary watch mode, auto-restart-until-mergeable, escalate,
|
|
11
|
+
* keep-working, auto-ensure, PR-placeholder restart) kept the PR marked as
|
|
12
|
+
* "ready for review" while the AI was actively working on it.
|
|
13
|
+
*
|
|
14
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2123
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// rate-limit marker (#1726): callers pass in a `$` already wrapped by wrapDollarWithGhRetry.
|
|
18
|
+
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs';
|
|
19
|
+
|
|
20
|
+
const noopLog = async () => {};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fetch the draft/open state of a pull request.
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} options
|
|
26
|
+
* @param {string} options.owner - Repository owner
|
|
27
|
+
* @param {string} options.repo - Repository name
|
|
28
|
+
* @param {number|string} options.prNumber - Pull request number
|
|
29
|
+
* @param {Function} options.$ - command-stream style tagged template executor
|
|
30
|
+
* @param {Function} [options.log] - Logger
|
|
31
|
+
* @returns {Promise<{ok: boolean, isDraft: (boolean|null), state: (string|null), merged: boolean, error: (string|null)}>}
|
|
32
|
+
*/
|
|
33
|
+
export const getPullRequestDraftState = async ({ owner, repo, prNumber, $, log = noopLog }) => {
|
|
34
|
+
try {
|
|
35
|
+
const result = await $`gh pr view ${prNumber} --repo ${owner}/${repo} --json isDraft,state`;
|
|
36
|
+
if (result.code !== 0) {
|
|
37
|
+
const stderr = result.stderr ? result.stderr.toString().trim() : '';
|
|
38
|
+
return { ok: false, isDraft: null, state: null, merged: false, error: stderr || `gh exited with code ${result.code}` };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const raw = result.stdout.toString().trim();
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(raw);
|
|
45
|
+
} catch {
|
|
46
|
+
return { ok: false, isDraft: null, state: null, merged: false, error: `Could not parse gh output: ${raw.slice(0, 200)}` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const state = typeof parsed.state === 'string' ? parsed.state.toUpperCase() : null;
|
|
50
|
+
await log(` 🔍 PR #${prNumber} draft state: isDraft=${parsed.isDraft}, state=${state}`, { verbose: true });
|
|
51
|
+
|
|
52
|
+
return { ok: true, isDraft: parsed.isDraft === true, state, merged: state === 'MERGED', error: null };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return { ok: false, isDraft: null, state: null, merged: false, error: error.message };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Internal helper shared by ensurePullRequestIsDraft/ensurePullRequestIsReady.
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} options
|
|
62
|
+
* @param {'draft'|'ready'} options.target - Desired state
|
|
63
|
+
* @returns {Promise<{ok: boolean, changed: boolean, skipped: boolean, reason: (string|null), error: (string|null)}>}
|
|
64
|
+
*/
|
|
65
|
+
const setPullRequestDraftState = async ({ target, owner, repo, prNumber, $, log = noopLog, formatAligned = null, indent = 2, reason = null, reportError = null }) => {
|
|
66
|
+
const wantDraft = target === 'draft';
|
|
67
|
+
const label = wantDraft ? 'draft mode' : 'ready for review';
|
|
68
|
+
const write = async (icon, key, value) => {
|
|
69
|
+
await log(formatAligned ? formatAligned(icon, key, value, indent) : `${icon} ${key} ${value}`);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (!owner || !repo || !prNumber) {
|
|
73
|
+
return { ok: false, changed: false, skipped: true, reason: 'missing_pr_context', error: null };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const status = await getPullRequestDraftState({ owner, repo, prNumber, $, log });
|
|
78
|
+
|
|
79
|
+
if (!status.ok) {
|
|
80
|
+
await log(`Warning: Could not check PR #${prNumber} draft status: ${status.error}`, { level: 'warning' });
|
|
81
|
+
return { ok: false, changed: false, skipped: false, reason: 'status_check_failed', error: status.error };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A merged or closed pull request cannot change its draft state; GitHub rejects it.
|
|
85
|
+
if (status.state && status.state !== 'OPEN') {
|
|
86
|
+
await write('ℹ️', 'PR status:', `${status.state.toLowerCase()} - skipping ${label} conversion`);
|
|
87
|
+
return { ok: true, changed: false, skipped: true, reason: `pr_${status.state.toLowerCase()}`, error: null };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (status.isDraft === wantDraft) {
|
|
91
|
+
await write('✅', 'PR status:', `Already in ${label}`);
|
|
92
|
+
return { ok: true, changed: false, skipped: true, reason: 'already_in_target_state', error: null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await write('📝', 'Converting PR:', `To ${label}${reason ? ` (${reason})` : ''}...`);
|
|
96
|
+
const convertResult = wantDraft ? await $`gh pr ready ${prNumber} --repo ${owner}/${repo} --undo` : await $`gh pr ready ${prNumber} --repo ${owner}/${repo}`;
|
|
97
|
+
|
|
98
|
+
if (convertResult.code === 0) {
|
|
99
|
+
await write('✅', 'PR converted:', `Now in ${label}`);
|
|
100
|
+
return { ok: true, changed: true, skipped: false, reason: null, error: null };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const stderr = convertResult.stderr ? convertResult.stderr.toString().trim() : '';
|
|
104
|
+
await log(`Warning: Could not convert PR #${prNumber} to ${label}${stderr ? `: ${stderr}` : ''}`, { level: 'warning' });
|
|
105
|
+
return { ok: false, changed: false, skipped: false, reason: 'conversion_failed', error: stderr || `gh exited with code ${convertResult.code}` };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (typeof reportError === 'function') {
|
|
108
|
+
reportError(error, {
|
|
109
|
+
context: wantDraft ? 'convert_pr_to_draft' : 'convert_pr_to_ready',
|
|
110
|
+
prNumber,
|
|
111
|
+
owner,
|
|
112
|
+
repo,
|
|
113
|
+
operation: 'pr_status_change',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
await log(`Warning: Could not check/convert PR #${prNumber} draft status: ${error.message}`, { level: 'warning' });
|
|
117
|
+
return { ok: false, changed: false, skipped: false, reason: 'exception', error: error.message };
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Put a pull request into draft mode when a working session starts/restarts/resumes.
|
|
123
|
+
* No-op when the PR is already a draft, merged, or closed.
|
|
124
|
+
*/
|
|
125
|
+
export const ensurePullRequestIsDraft = async options => setPullRequestDraftState({ ...options, target: 'draft' });
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Put a pull request back to "ready for review" when a working session ends.
|
|
129
|
+
* No-op when the PR is already ready, merged, or closed.
|
|
130
|
+
*/
|
|
131
|
+
export const ensurePullRequestIsReady = async options => setPullRequestDraftState({ ...options, target: 'ready' });
|
|
132
|
+
|
|
133
|
+
export default {
|
|
134
|
+
getPullRequestDraftState,
|
|
135
|
+
ensurePullRequestIsDraft,
|
|
136
|
+
ensurePullRequestIsReady,
|
|
137
|
+
};
|
|
@@ -162,6 +162,14 @@ export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, sho
|
|
|
162
162
|
await log(`🔄 Session will be RESTARTED (fresh start without previous context)`);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// Issue #2123: the resumed/restarted process is launched with the ISSUE url, so without
|
|
166
|
+
// --auto-continue it would not enter continue mode, would not find the existing PR, and
|
|
167
|
+
// therefore would never convert that PR back to draft (nor post the auto-resume/auto-restart
|
|
168
|
+
// session comment). Preserve the flag so the new session attaches to the same PR.
|
|
169
|
+
if (argv.autoContinue) {
|
|
170
|
+
resumeArgs.push('--auto-continue');
|
|
171
|
+
}
|
|
172
|
+
|
|
165
173
|
// Preserve auto-resume/auto-restart flag for subsequent limit hits
|
|
166
174
|
if (argv.autoResumeOnLimitReset) {
|
|
167
175
|
resumeArgs.push('--auto-resume-on-limit-reset');
|
|
@@ -33,6 +33,8 @@ const lib = await import('./lib.mjs');
|
|
|
33
33
|
const { log, formatAligned, extractToolErrorCore } = lib;
|
|
34
34
|
const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
|
|
35
35
|
const { RESOURCE_PHASE_RESTART_AFTER, RESOURCE_PHASE_RESTART_BEFORE, recordResourceSnapshot } = await import('./solve.resource-diagnostics.lib.mjs');
|
|
36
|
+
// Issue #2123: shared draft/ready transitions for working sessions.
|
|
37
|
+
const { ensurePullRequestIsDraft } = await import('./pr-draft-state.lib.mjs');
|
|
36
38
|
|
|
37
39
|
// Import Sentry integration
|
|
38
40
|
const sentryLib = await import('./sentry.lib.mjs');
|
|
@@ -185,6 +187,23 @@ export const executeToolIteration = async params => {
|
|
|
185
187
|
label: 'before AI restart iteration',
|
|
186
188
|
});
|
|
187
189
|
|
|
190
|
+
// Issue #2123: every restart/resume iteration is a new working session, so the PR must be
|
|
191
|
+
// put back into draft before the AI starts changing it. This single call covers watch mode,
|
|
192
|
+
// temporary auto-restart, auto-restart-until-mergeable, escalate, keep-working,
|
|
193
|
+
// auto-ensure-requirements and the PR-placeholder restart, which all funnel through here.
|
|
194
|
+
if (prNumber) {
|
|
195
|
+
await ensurePullRequestIsDraft({
|
|
196
|
+
owner,
|
|
197
|
+
repo,
|
|
198
|
+
prNumber,
|
|
199
|
+
$,
|
|
200
|
+
log,
|
|
201
|
+
formatAligned,
|
|
202
|
+
reason: 'restart iteration',
|
|
203
|
+
reportError,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
188
207
|
// Import necessary modules for tool execution
|
|
189
208
|
const memoryCheck = await import('./memory-check.mjs');
|
|
190
209
|
const { getResourceSnapshot } = memoryCheck;
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
import { AI_WORK_SESSION_STARTED_MARKER, AI_WORK_SESSION_COMPLETED_MARKER, AI_WORK_SESSION_RESUMED_MARKER, AUTO_RESUME_ON_LIMIT_RESET_MARKER, AUTO_RESTART_ON_LIMIT_RESET_MARKER, postTrackedComment } from './tool-comments.lib.mjs';
|
|
10
10
|
|
|
11
11
|
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
|
|
12
|
+
|
|
13
|
+
// Issue #2123: draft/ready transitions live in one shared module so every session
|
|
14
|
+
// start/restart/resume path behaves identically.
|
|
15
|
+
import { ensurePullRequestIsDraft, ensurePullRequestIsReady } from './pr-draft-state.lib.mjs';
|
|
12
16
|
/**
|
|
13
17
|
* Session type definitions for different work session contexts
|
|
14
18
|
* See: https://github.com/link-assistant/hive-mind/issues/1152
|
|
@@ -70,39 +74,30 @@ function getSessionCommentContent(sessionType, timestamp) {
|
|
|
70
74
|
* @param {string} [options.sessionType='new'] - One of SESSION_TYPES values
|
|
71
75
|
*/
|
|
72
76
|
export async function startWorkSession({ isContinueMode, prNumber, argv, log, formatAligned, $, sessionType = SESSION_TYPES.NEW }) {
|
|
73
|
-
// Record work start time and convert PR to draft
|
|
77
|
+
// Record work start time and convert PR to draft.
|
|
78
|
+
//
|
|
79
|
+
// Issue #2123: the draft conversion used to be gated behind `argv.watch || argv.autoContinue`,
|
|
80
|
+
// so plain `--resume`/continue-mode sessions left the PR marked "ready for review" while the
|
|
81
|
+
// AI was still working on it. Any continue-mode session with a PR now converts it to draft.
|
|
74
82
|
const workStartTime = new Date();
|
|
75
|
-
|
|
83
|
+
const shouldPostSessionComment = argv.watch || argv.autoContinue;
|
|
84
|
+
if (isContinueMode && prNumber) {
|
|
76
85
|
await log(`\n${formatAligned('🚀', 'Starting work session:', workStartTime.toISOString())}`);
|
|
77
86
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
} else {
|
|
92
|
-
await log(formatAligned('✅', 'PR status:', 'Already in draft mode', 2));
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
} catch (error) {
|
|
96
|
-
const sentryLib = await import('./sentry.lib.mjs');
|
|
97
|
-
const { reportError } = sentryLib;
|
|
98
|
-
reportError(error, {
|
|
99
|
-
context: 'convert_pr_to_draft',
|
|
100
|
-
prNumber,
|
|
101
|
-
operation: 'pr_status_change',
|
|
102
|
-
});
|
|
103
|
-
await log('Warning: Could not check/convert PR draft status', { level: 'warning' });
|
|
104
|
-
}
|
|
87
|
+
const { reportError } = await import('./sentry.lib.mjs');
|
|
88
|
+
await ensurePullRequestIsDraft({
|
|
89
|
+
owner: global.owner,
|
|
90
|
+
repo: global.repo,
|
|
91
|
+
prNumber,
|
|
92
|
+
$,
|
|
93
|
+
log,
|
|
94
|
+
formatAligned,
|
|
95
|
+
reason: `session start: ${sessionType}`,
|
|
96
|
+
reportError,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
105
99
|
|
|
100
|
+
if (isContinueMode && prNumber && shouldPostSessionComment) {
|
|
106
101
|
// Post a comment marking the start of work session with appropriate header based on session type.
|
|
107
102
|
// Issue #1625: Use postTrackedComment so the comment ID is registered in-memory and can be
|
|
108
103
|
// excluded from the "did the AI post anything?" check in checkForAiCreatedComments().
|
|
@@ -131,14 +126,17 @@ export async function startWorkSession({ isContinueMode, prNumber, argv, log, fo
|
|
|
131
126
|
}
|
|
132
127
|
|
|
133
128
|
export async function endWorkSession({ isContinueMode, prNumber, argv, log, formatAligned, $, logsAttached = false }) {
|
|
134
|
-
// Post end work session comment and convert PR back to ready if in continue mode
|
|
135
|
-
|
|
129
|
+
// Post end work session comment and convert PR back to ready if in continue mode.
|
|
130
|
+
// Issue #2123: the ready conversion mirrors startWorkSession's draft conversion, so it must
|
|
131
|
+
// run for every continue-mode session, not only for --watch/--auto-continue ones.
|
|
132
|
+
if (isContinueMode && prNumber) {
|
|
136
133
|
const workEndTime = new Date();
|
|
134
|
+
const shouldPostSessionComment = argv.watch || argv.autoContinue;
|
|
137
135
|
await log(`\n${formatAligned('🏁', 'Ending work session:', workEndTime.toISOString())}`);
|
|
138
136
|
|
|
139
137
|
// Only post end comment if logs were NOT already attached
|
|
140
138
|
// The attachLogToGitHub comment already serves as finishing status with "Now working session is ended" text
|
|
141
|
-
if (!logsAttached) {
|
|
139
|
+
if (shouldPostSessionComment && !logsAttached) {
|
|
142
140
|
// Post a comment marking the end of work session.
|
|
143
141
|
// Issue #1625: Track the comment ID so it won't be mistaken for AI-authored content.
|
|
144
142
|
try {
|
|
@@ -159,36 +157,21 @@ export async function endWorkSession({ isContinueMode, prNumber, argv, log, form
|
|
|
159
157
|
});
|
|
160
158
|
await log('Warning: Could not post work end comment', { level: 'warning' });
|
|
161
159
|
}
|
|
162
|
-
} else {
|
|
160
|
+
} else if (shouldPostSessionComment) {
|
|
163
161
|
await log(formatAligned('ℹ️', 'Skipping:', 'End comment (logs already attached with session end message)', 2));
|
|
164
162
|
}
|
|
165
163
|
|
|
166
|
-
// Convert PR back to ready for review
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
} else {
|
|
180
|
-
await log(formatAligned('✅', 'PR status:', 'Already ready for review', 2));
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
} catch (error) {
|
|
184
|
-
const sentryLib = await import('./sentry.lib.mjs');
|
|
185
|
-
const { reportError } = sentryLib;
|
|
186
|
-
reportError(error, {
|
|
187
|
-
context: 'convert_pr_to_ready',
|
|
188
|
-
prNumber,
|
|
189
|
-
operation: 'pr_status_change',
|
|
190
|
-
});
|
|
191
|
-
await log('Warning: Could not convert PR to ready status', { level: 'warning' });
|
|
192
|
-
}
|
|
164
|
+
// Convert PR back to ready for review (issue #2123: shared implementation)
|
|
165
|
+
const { reportError } = await import('./sentry.lib.mjs');
|
|
166
|
+
await ensurePullRequestIsReady({
|
|
167
|
+
owner: global.owner,
|
|
168
|
+
repo: global.repo,
|
|
169
|
+
prNumber,
|
|
170
|
+
$,
|
|
171
|
+
log,
|
|
172
|
+
formatAligned,
|
|
173
|
+
reason: 'session end',
|
|
174
|
+
reportError,
|
|
175
|
+
});
|
|
193
176
|
}
|
|
194
177
|
}
|