claude-dev-env 1.81.0 → 1.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/conventional_pr_title_gate.py +444 -0
- package/hooks/blocking/test_conventional_pr_title_gate.py +640 -0
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +58 -0
- package/package.json +1 -1
- package/skills/autoconverge/SKILL.md +90 -5
- package/skills/autoconverge/reference/gotchas.md +5 -5
- package/skills/autoconverge/workflow/converge.contract.test.mjs +159 -27
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +104 -7
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +2 -2
- package/skills/autoconverge/workflow/converge.mjs +62 -22
- package/skills/autoconverge/workflow/converge_multi.mjs +6 -2
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +26 -0
|
@@ -35,7 +35,11 @@ const HEADLESS_SAFETY_PREAMBLE =
|
|
|
35
35
|
'- When a commit message, or a PR / issue / review-comment body, must describe destructive-command behavior, write that text to a file and pass it by path (git commit -F <file>, gh ... --body-file <file>); never inline it with git commit -m or gh ... -b, where the literal lands in the Bash command and stalls you.\n' +
|
|
36
36
|
'- Keep scratch files and cleanup inside the OS temp dir; never target a repository or worktree path.\n' +
|
|
37
37
|
'- rm shape rules — the hook grants several rm auto-allow paths. The simplest one accepts a standalone Bash call whose target resolves inside the ephemeral namespace (/tmp, /temp, the OS temp root, or the run worktree); a compound path accepts an rm joined with benign reporting segments when every rm target is an absolute ephemeral path. Both of those paths fail closed on $(...) command substitution and on backtick subshells. The compound path additionally fails closed on any $ in the target — including $CLAUDE_JOB_DIR. The standalone path declines a $-bearing target only when the literal path is not already under an ephemeral root, so it does not by itself stop a $VAR that expands inside an ephemeral root. A third, broad path matches only when the command itself declares an ephemeral working directory (it cds into one, or runs under one): that cwd-scoped path resolves the target against the declared cwd, fails closed on $(...) , backticks, and unknown variables, and resolves the known temporary variables TEMP, TMP, TMPDIR, and CLAUDE_JOB_DIR to the OS temp root, so under that declared ephemeral cwd a bare $CLAUDE_JOB_DIR/tmp/<name> target and a relative target after a cd are auto-allowed. Even so, prefer a Python helper for any cleanup whose path is variable-built or whose setup/teardown spans multiple steps: author the helper file and run it as python <file>.py, which keeps every destructive literal out of a Bash command string entirely and never depends on which auto-allow path matches.\n' +
|
|
38
|
-
'- If a step appears to require a real destructive command, use a non-destructive equivalent or report it as a blocker instead of running it.\n\n'
|
|
38
|
+
'- If a step appears to require a real destructive command, use a non-destructive equivalent or report it as a blocker instead of running it.\n\n' +
|
|
39
|
+
'WAITS AND POLLS — foreground sleep is blocked in this headless harness: a bare Bash "sleep N" or a PowerShell "Start-Sleep" is denied, and a wait you move to a background process — then end your turn to await it — never resumes, because a schema-bearing agent runs for a single turn. Therefore:\n' +
|
|
40
|
+
'- Perform every required delay or poll-interval wait inside this same turn by pairing the Monitor tool with a bounded until-loop: the Monitor tool streams its events to you while you keep working, and the until-loop re-runs the step condition on the interval the step names, up to the attempt budget the step names, exiting the moment the condition holds or the budget is spent — consume the Monitor notifications as they arrive rather than ending your turn to await them. Never end your turn to wait for something to finish.\n' +
|
|
41
|
+
'- Size the Monitor to the whole wait: its timeout_ms defaults to 300000 (300 seconds) and the Monitor is killed the moment that elapses, so a poll whose interval or whose interval-times-attempts span runs longer registers a false time-out. Before you arm a Monitor, set timeout_ms to at least the poll interval times the attempt count the step names — up to the 3600000ms ceiling — or pass persistent: true so the Monitor is never timed out. A 360-second interval, or a many-minute total, exceeds the 300000 default, so arming the Monitor with the default truncates the wait; set timeout_ms to the step span instead.\n' +
|
|
42
|
+
'- When your run was given a result schema, your final action is always the StructuredOutput call. If the poll budget is spent before the awaited signal arrives, call StructuredOutput with the whole time-out result the step documents — for the Copilot gate, the full down result {sha, clean:false, down:true, findings:[]}, never a bare down flag — rather than ending the turn without a result.\n\n'
|
|
39
43
|
|
|
40
44
|
let activeRepoPath = null
|
|
41
45
|
|
|
@@ -102,7 +106,7 @@ function runGitTask(task, head) {
|
|
|
102
106
|
`Report whether ${prCoordinates} (HEAD ${head}) has merge conflicts with its base branch. Do not edit, commit, push, or rebase — read only.\n\n` +
|
|
103
107
|
`GitHub computes mergeability asynchronously, so .mergeable is null right after a push until it finishes. Poll until it resolves: run\n` +
|
|
104
108
|
` gh api repos/${input.owner}/${input.repo}/pulls/${input.prNumber} --jq '{mergeable: .mergeable, state: .mergeable_state}'\n` +
|
|
105
|
-
`up to 5 times, 5 seconds apart (
|
|
109
|
+
`up to 5 times, 5 seconds apart (wait each 5-second interval inside this turn with the Monitor tool, per the WAITS AND POLLS rule above), stopping as soon as mergeable is true or false.\n\n` +
|
|
106
110
|
`Return conflicting:true when mergeable is false or state is "dirty" (the branch conflicts with the base). Return conflicting:false when mergeable is true, or when mergeable stays null after the full poll budget — mergeability is unknown, so let the bug checks proceed rather than rebase on a guess.`,
|
|
107
111
|
{ label: 'git-utility', phase: 'Converge', schema: MERGE_CONFLICT_SCHEMA, agentType: 'Explore' },
|
|
108
112
|
)
|
|
@@ -205,7 +209,7 @@ async function fixerWithRecovery(head, findings, sourceLabel) {
|
|
|
205
209
|
* Each task carries its own edit instructions.
|
|
206
210
|
* @param {string} task the short task name
|
|
207
211
|
* @param {object} context task-specific context
|
|
208
|
-
* @returns {Promise<object|string>} the structured output, or the transcript string when a schema-less task ('
|
|
212
|
+
* @returns {Promise<object|string>} the structured output, or the transcript string when a schema-less task ('standards-resolve-threads') runs
|
|
209
213
|
*/
|
|
210
214
|
function runCodeEditorTask(task, context) {
|
|
211
215
|
const label = `code-editor:${task}`
|
|
@@ -312,9 +316,10 @@ function runCodeEditorTask(task, context) {
|
|
|
312
316
|
`You are the COMMIT step opening the environment-hardening PR (${context.sourceLabel}) for the change staged in ${context.hardeningRepoPath} on branch ${context.hardeningBranch}. The edit step left the hooks/rules edits in the working tree and the verify step passed, so a verifier verdict already binds to this exact working tree. Do NOT touch the PR's own branch.\n\n` +
|
|
313
317
|
`Rules:\n` +
|
|
314
318
|
`- Make NO further file edits of any kind. Any edit changes the surface and invalidates the verdict that unlocks the commit gate, so the push would be blocked. Only commit and push what is already there.\n` +
|
|
315
|
-
`- In ${context.hardeningRepoPath}: make ONE commit of the staged hooks/rules change on branch ${context.hardeningBranch}, push it, then open a DRAFT PR. The PR body references the follow-up issue ${context.issueUrl || '(none)'} and states the PR hardens the environment so the deferred violation classes are blocked at Write/Edit time. Honor the gh-body-file rule: write a BOM-free temp file and pass --body-file.\n
|
|
316
|
-
|
|
317
|
-
|
|
319
|
+
`- In ${context.hardeningRepoPath}: make ONE commit of the staged hooks/rules change on branch ${context.hardeningBranch}, push it, then open a DRAFT PR. The PR body references the follow-up issue ${context.issueUrl || '(none)'} and states the PR hardens the environment so the deferred violation classes are blocked at Write/Edit time. Honor the gh-body-file rule: write a BOM-free temp file and pass --body-file.\n` +
|
|
320
|
+
`- Title the PR as a Conventional Commit — a type prefix (feat, fix, chore, docs, refactor, perf, ci, style, test, build, revert), an optional scope in parentheses, then a colon and a short summary, e.g. "feat(hooks): block the deferred violation class". The target repo's CI validates the PR title as a semantic commit and rejects a non-conforming title.\n\n` +
|
|
321
|
+
`Return the full https URL of the DRAFT hardening PR in hardeningPrUrl (empty string when no PR was opened) and a one-line summary.`,
|
|
322
|
+
{ label, phase: 'Converge', schema: HARDENING_COMMIT_SCHEMA, agentType: 'clean-coder' },
|
|
318
323
|
)
|
|
319
324
|
}
|
|
320
325
|
if (task === 'commit-recover') {
|
|
@@ -583,6 +588,16 @@ const STANDARDS_EDIT_SCHEMA = {
|
|
|
583
588
|
required: ['issueUrl', 'hardeningRepoPath', 'hardeningBranch', 'hardeningEdited', 'summary'],
|
|
584
589
|
}
|
|
585
590
|
|
|
591
|
+
const HARDENING_COMMIT_SCHEMA = {
|
|
592
|
+
type: 'object',
|
|
593
|
+
additionalProperties: false,
|
|
594
|
+
properties: {
|
|
595
|
+
hardeningPrUrl: { type: 'string', description: 'the full https URL of the DRAFT environment-hardening PR the commit step opened, or an empty string when no PR was opened' },
|
|
596
|
+
summary: { type: 'string' },
|
|
597
|
+
},
|
|
598
|
+
required: ['hardeningPrUrl', 'summary'],
|
|
599
|
+
}
|
|
600
|
+
|
|
586
601
|
/**
|
|
587
602
|
* Build the verdict-fence step instructions for a verify agent, binding the
|
|
588
603
|
* surface hash by branch name rather than by a self-resolved cwd. Resolving
|
|
@@ -1105,7 +1120,7 @@ function runBugbotLens(head) {
|
|
|
1105
1120
|
` Only count entries whose commit_id starts with ${head}.\n` +
|
|
1106
1121
|
` - If findings exist on HEAD -> return them (each with its inline comment id in replyToCommentId when present, else null).\n` +
|
|
1107
1122
|
` - If a clean review exists on HEAD -> return clean.\n` +
|
|
1108
|
-
`4. No review yet on HEAD: check_bugbot_ci.py --check-active. If active (exit 0), poll: repeat check_bugbot_ci.py --check-clean / --check-active every 60 seconds (
|
|
1123
|
+
`4. No review yet on HEAD: check_bugbot_ci.py --check-active. If active (exit 0), poll: repeat check_bugbot_ci.py --check-clean / --check-active every 60 seconds (wait each 60-second interval inside this turn with the Monitor tool, per the WAITS AND POLLS rule above) for up to 25 iterations, then re-fetch the review. If not active (exit 1), post the literal comment "bugbot run" (no @mention, no other text) via python "${CONFIG.sharedScripts}/post_fix_reply.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --body "bugbot run", wait 8 seconds inside this turn with the Monitor tool (per the WAITS AND POLLS rule above), then poll as above.\n` +
|
|
1109
1124
|
`5. If after the full poll budget Bugbot has neither a check run nor a review on HEAD -> return {sha:${'`'}${head}${'`'}, clean:true, down:true, findings:[]} (treat as down).\n\n` +
|
|
1110
1125
|
`Scope is the whole PR; you are only reading Bugbot's own output here. For each finding set category: 'code-standard' when it is a pure CODE_RULES/style violation (naming, comments, type hints, magic values, structure) with no behavioral impact; 'bug' otherwise. Return strictly the schema.`,
|
|
1111
1126
|
{ label: 'lens:bugbot', phase: 'Converge', schema: LENS_SCHEMA },
|
|
@@ -1300,7 +1315,7 @@ function runCopilotGate(head) {
|
|
|
1300
1315
|
`Copilot can run out of usage. When the newest Copilot review on HEAD carries an out-of-usage notice — a body stating Copilot was unable to review because the user who requested the review has reached their quota limit, or any equivalent quota / premium-request / usage-limit exhaustion message rather than an actual code review — Copilot is down for this run: return {sha:${'`'}${head}${'`'}, clean:true, down:true, findings:[]} and stop. Do NOT re-request a review, do NOT keep polling, and do NOT treat the notice as a finding.\n\n` +
|
|
1301
1316
|
`1. Read any existing Copilot review on HEAD first: python "${CONFIG.sharedScripts}/fetch_copilot_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber}. This lists every Copilot review across all commits newest-first; only count entries whose commit_id starts with ${head}. If the newest such HEAD-scoped Copilot review is the out-of-usage notice above -> return the down result and stop. A notice on any earlier commit is NOT down: ignore it and continue. With no Copilot review on HEAD, skip a duplicate request: python "${CONFIG.sharedScripts}/check_pending_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --user copilot. Exit 0 means a request is already pending; otherwise request one:\n` +
|
|
1302
1317
|
` gh api --method POST repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/requested_reviewers -f 'reviewers[]=copilot-pull-request-reviewer[bot]'\n` +
|
|
1303
|
-
`2. Poll for Copilot's review on HEAD ${head}: up to ${CONFIG.copilotMaxPolls} attempts, 360 seconds apart (
|
|
1318
|
+
`2. Poll for Copilot's review on HEAD ${head}: up to ${CONFIG.copilotMaxPolls} attempts, 360 seconds apart (wait each 360-second interval inside this turn with the Monitor tool, per the WAITS AND POLLS rule above; if the attempt budget is spent with no review on HEAD, return down: true). Each attempt: python "${CONFIG.sharedScripts}/fetch_copilot_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} for the top-level review state, plus gh api "repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/comments" --paginate --slurp for inline comment ids (Copilot's login contains "copilot", case-insensitive). Only count entries whose commit_id starts with ${head}.\n` +
|
|
1304
1319
|
` - Out-of-usage notice on HEAD -> return the down result above (clean:true, down:true) and stop.\n` +
|
|
1305
1320
|
` - Copilot review present on HEAD whose state is APPROVED, or COMMENTED with no inline findings -> a clean pass: return {sha:${'`'}${head}${'`'}, clean:true, down:false, findings:[]}.\n` +
|
|
1306
1321
|
` - Copilot findings on HEAD -> return them (each with its inline comment id in replyToCommentId; category 'code-standard' for pure CODE_RULES/style violations with no behavioral impact, 'bug' otherwise), clean:false, down:false.\n` +
|
|
@@ -1436,6 +1451,25 @@ function standardsDeferralNote(findingsCount, wasHardeningPrOpened) {
|
|
|
1436
1451
|
: `${base} — verify it lands (no environment-hardening PR was opened for this run)`
|
|
1437
1452
|
}
|
|
1438
1453
|
|
|
1454
|
+
/**
|
|
1455
|
+
* Parse a GitHub pull-request URL into the owner, repo, and number a recursive
|
|
1456
|
+
* converge run needs to address it.
|
|
1457
|
+
*
|
|
1458
|
+
* A hardening PR the commit step opens returns its URL as
|
|
1459
|
+
* `https://github.com/<owner>/<repo>/pull/<number>`; this reads those three
|
|
1460
|
+
* coordinates back out so the self-closing orchestrator can converge the
|
|
1461
|
+
* deferred PR in turn. A blank or non-matching string yields null, so a commit
|
|
1462
|
+
* step that opened no PR contributes no deferred coordinate.
|
|
1463
|
+
* @param {string} prUrl the hardening PR's https URL, or an empty string
|
|
1464
|
+
* @returns {{owner: string, repo: string, prNumber: number}|null} the parsed coordinates, or null when the URL does not match
|
|
1465
|
+
*/
|
|
1466
|
+
function parseDeferredPr(prUrl) {
|
|
1467
|
+
if (typeof prUrl !== 'string') return null
|
|
1468
|
+
const match = prUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?(?:[?#].*)?$/)
|
|
1469
|
+
if (!match) return null
|
|
1470
|
+
return { owner: match[1], repo: match[2], prNumber: Number(match[3]) }
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1439
1473
|
/**
|
|
1440
1474
|
* Defer a standards-only round: edit (clean-coder files the follow-up fix issue,
|
|
1441
1475
|
* stages an environment-hardening hooks/rules change in the config repo's
|
|
@@ -1451,28 +1485,30 @@ function standardsDeferralNote(findingsCount, wasHardeningPrOpened) {
|
|
|
1451
1485
|
* @param {Array<object>} findings deduped code-standard-only findings
|
|
1452
1486
|
* @param {string} sourceLabel short description of where the findings came from
|
|
1453
1487
|
* @param {boolean} hasHardeningPrAlreadyOpened true when an earlier round already opened the environment-hardening PR for this run, so the verify and commit steps are skipped and no second PR opens while the edit retries the issue filing
|
|
1454
|
-
* @returns {Promise<object>} `{ followUpIssueFiled, issueUrl, hardeningPrOpened }` — followUpIssueFiled true when the standards-edit step returned a non-empty issue URL, issueUrl that filed URL (empty string when the filing failed) so a later reuse-path round can reference it when resolving its own threads, hardeningPrOpened true
|
|
1488
|
+
* @returns {Promise<object>} `{ followUpIssueFiled, issueUrl, hardeningPrOpened, deferredPr }` — followUpIssueFiled true when the standards-edit step returned a non-empty issue URL, issueUrl that filed URL (empty string when the filing failed) so a later reuse-path round can reference it when resolving its own threads, hardeningPrOpened true when the hardening-commit step returned a non-empty hardeningPrUrl (a PR opened) so the run-once latch holds even when that URL does not parse into coordinates, and false when the commit step returned an empty URL (no PR opened) so a later round retries the open, and deferredPr the opened PR's `{owner, repo, prNumber}` (null when no PR was opened or the committed URL does not parse) so the self-closing orchestrator can converge it in turn
|
|
1455
1489
|
*/
|
|
1456
1490
|
async function spawnStandardsFollowUp(head, findings, sourceLabel, hasHardeningPrAlreadyOpened) {
|
|
1457
1491
|
const editResult = await runCodeEditorTask('standards-edit', { head, findings, sourceLabel })
|
|
1458
1492
|
const followUpIssueFiled = typeof editResult?.issueUrl === 'string' && editResult.issueUrl.length > 0
|
|
1459
1493
|
const followUpIssueUrl = followUpIssueFiled ? editResult.issueUrl : ''
|
|
1460
1494
|
if (hasHardeningPrAlreadyOpened === true) {
|
|
1461
|
-
return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false }
|
|
1495
|
+
return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false, deferredPr: null }
|
|
1462
1496
|
}
|
|
1463
1497
|
if (editResult?.hardeningEdited !== true || !editResult?.hardeningRepoPath) {
|
|
1464
|
-
return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false }
|
|
1498
|
+
return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false, deferredPr: null }
|
|
1465
1499
|
}
|
|
1466
1500
|
const verifyTranscript = await runVerifierTask('hardening-verify', {
|
|
1467
1501
|
head, sourceLabel, hardeningRepoPath: editResult.hardeningRepoPath, hardeningBranch: editResult.hardeningBranch,
|
|
1468
1502
|
})
|
|
1469
1503
|
if (!verdictPassed(verifyTranscript)) {
|
|
1470
|
-
return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false }
|
|
1504
|
+
return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false, deferredPr: null }
|
|
1471
1505
|
}
|
|
1472
|
-
await runCodeEditorTask('hardening-commit', {
|
|
1506
|
+
const commitResult = await runCodeEditorTask('hardening-commit', {
|
|
1473
1507
|
head, sourceLabel, hardeningRepoPath: editResult.hardeningRepoPath, hardeningBranch: editResult.hardeningBranch, issueUrl: editResult.issueUrl,
|
|
1474
1508
|
})
|
|
1475
|
-
|
|
1509
|
+
const deferredPr = parseDeferredPr(commitResult?.hardeningPrUrl)
|
|
1510
|
+
const hardeningPrOpened = typeof commitResult?.hardeningPrUrl === 'string' && commitResult.hardeningPrUrl.length > 0
|
|
1511
|
+
return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened, deferredPr }
|
|
1476
1512
|
}
|
|
1477
1513
|
|
|
1478
1514
|
/**
|
|
@@ -1525,13 +1561,13 @@ async function resolveStandardsThreadsForBatch(head, findings, sourceLabel) {
|
|
|
1525
1561
|
* @param {string} head PR HEAD SHA the findings were raised against
|
|
1526
1562
|
* @param {Array<object>} findings deduped code-standard-only findings
|
|
1527
1563
|
* @param {string} sourceLabel short description of where the findings came from
|
|
1528
|
-
* @returns {Promise<
|
|
1564
|
+
* @returns {Promise<object>} `{ hardeningPrOpened, deferredPr }` — hardeningPrOpened true when a hardening PR was opened for this run, and deferredPr the opened PR's `{owner, repo, prNumber}` when this call opened it (null otherwise) so the self-closing orchestrator can converge it in turn
|
|
1529
1565
|
*/
|
|
1530
1566
|
async function openStandardsFollowUpOnce(head, findings, sourceLabel) {
|
|
1531
1567
|
if (!shouldOpenStandardsFollowUp(hasStandardsFollowUpFiled)) {
|
|
1532
1568
|
log(`Standards deferral (${sourceLabel}): reusing the follow-up fix issue already filed for this run rather than filing a duplicate; environment-hardening PR ${wasStandardsHardeningPrOpened ? 'was opened for this run' : 'was not opened for this run'}`)
|
|
1533
1569
|
await resolveStandardsThreadsForBatch(head, findings, sourceLabel)
|
|
1534
|
-
return wasStandardsHardeningPrOpened
|
|
1570
|
+
return { hardeningPrOpened: wasStandardsHardeningPrOpened, deferredPr: null }
|
|
1535
1571
|
}
|
|
1536
1572
|
const standardsOutcome = await spawnStandardsFollowUp(head, findings, sourceLabel, wasStandardsHardeningPrOpened)
|
|
1537
1573
|
hasStandardsFollowUpFiled = standardsOutcome?.followUpIssueFiled === true
|
|
@@ -1539,7 +1575,7 @@ async function openStandardsFollowUpOnce(head, findings, sourceLabel) {
|
|
|
1539
1575
|
standardsFollowUpIssueUrl = standardsOutcome.issueUrl
|
|
1540
1576
|
}
|
|
1541
1577
|
wasStandardsHardeningPrOpened = wasStandardsHardeningPrOpened || standardsOutcome?.hardeningPrOpened === true
|
|
1542
|
-
return wasStandardsHardeningPrOpened
|
|
1578
|
+
return { hardeningPrOpened: wasStandardsHardeningPrOpened, deferredPr: standardsOutcome?.deferredPr ?? null }
|
|
1543
1579
|
}
|
|
1544
1580
|
|
|
1545
1581
|
let phase = 'CONVERGE'
|
|
@@ -1555,6 +1591,7 @@ let hasStandardsFollowUpFiled = false
|
|
|
1555
1591
|
let wasStandardsHardeningPrOpened = false
|
|
1556
1592
|
let standardsFollowUpIssueUrl = ''
|
|
1557
1593
|
let reuseNote = null
|
|
1594
|
+
const deferredPrs = []
|
|
1558
1595
|
|
|
1559
1596
|
const preflightHead = await runGitTask('resolve-head')
|
|
1560
1597
|
if (isResolvedHeadUsable(preflightHead?.sha)) {
|
|
@@ -1607,8 +1644,9 @@ while (iterations < CONFIG.maxIterations) {
|
|
|
1607
1644
|
const findings = roundOutcome.findings
|
|
1608
1645
|
if (isStandardsOnlyRound(findings)) {
|
|
1609
1646
|
log(`Round ${rounds}: ${findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the round as passed`)
|
|
1610
|
-
const
|
|
1611
|
-
standardsNote = standardsDeferralNote(findings.length,
|
|
1647
|
+
const standardsOutcome = await openStandardsFollowUpOnce(head, findings, 'converge-round')
|
|
1648
|
+
standardsNote = standardsDeferralNote(findings.length, standardsOutcome.hardeningPrOpened)
|
|
1649
|
+
if (standardsOutcome?.deferredPr) deferredPrs.push(standardsOutcome.deferredPr)
|
|
1612
1650
|
const auditResult = await runGeneralUtilityTask('post-clean-audit', { head })
|
|
1613
1651
|
if (!auditResult?.posted) {
|
|
1614
1652
|
blocker = cleanAuditBlocker(head, auditResult)
|
|
@@ -1670,8 +1708,9 @@ while (iterations < CONFIG.maxIterations) {
|
|
|
1670
1708
|
if (copilotOutcome.kind === 'fix') {
|
|
1671
1709
|
if (isStandardsOnlyRound(copilotOutcome.findings)) {
|
|
1672
1710
|
log(`Copilot raised ${copilotOutcome.findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the gate as passed`)
|
|
1673
|
-
const
|
|
1674
|
-
standardsNote = standardsDeferralNote(copilotOutcome.findings.length,
|
|
1711
|
+
const standardsOutcome = await openStandardsFollowUpOnce(head, copilotOutcome.findings, 'copilot')
|
|
1712
|
+
standardsNote = standardsDeferralNote(copilotOutcome.findings.length, standardsOutcome.hardeningPrOpened)
|
|
1713
|
+
if (standardsOutcome?.deferredPr) deferredPrs.push(standardsOutcome.deferredPr)
|
|
1675
1714
|
copilotDown = false
|
|
1676
1715
|
copilotNote = null
|
|
1677
1716
|
phase = 'FINALIZE'
|
|
@@ -1707,7 +1746,7 @@ while (iterations < CONFIG.maxIterations) {
|
|
|
1707
1746
|
const readyResult = await runGeneralUtilityTask('mark-ready', { head, copilotDown })
|
|
1708
1747
|
const readyOutcome = classifyReadyOutcome(readyResult)
|
|
1709
1748
|
if (readyOutcome.converged) {
|
|
1710
|
-
return { converged: true, rounds, finalSha: head, blocker: null, standardsNote, copilotNote, reuseNote }
|
|
1749
|
+
return { converged: true, rounds, finalSha: head, blocker: null, standardsNote, copilotNote, reuseNote, deferredPrs }
|
|
1711
1750
|
}
|
|
1712
1751
|
blocker = readyOutcome.blocker
|
|
1713
1752
|
break
|
|
@@ -1727,4 +1766,5 @@ return {
|
|
|
1727
1766
|
standardsNote,
|
|
1728
1767
|
copilotNote,
|
|
1729
1768
|
reuseNote,
|
|
1769
|
+
deferredPrs,
|
|
1730
1770
|
}
|
|
@@ -126,7 +126,7 @@ function childRunInput(prEntry) {
|
|
|
126
126
|
|
|
127
127
|
const multiInput = classifyMultiInput(args)
|
|
128
128
|
if (multiInput.blocker) {
|
|
129
|
-
return { converged: false, prCount: 0, convergedCount: 0, results: [], blocker: multiInput.blocker }
|
|
129
|
+
return { converged: false, prCount: 0, convergedCount: 0, results: [], allDeferredPrs: [], blocker: multiInput.blocker }
|
|
130
130
|
}
|
|
131
131
|
const input = multiInput.input
|
|
132
132
|
|
|
@@ -147,6 +147,7 @@ const childResults = await parallel(
|
|
|
147
147
|
rounds: childOutcome && childOutcome.rounds !== undefined ? childOutcome.rounds : null,
|
|
148
148
|
finalSha: childOutcome && childOutcome.finalSha !== undefined ? childOutcome.finalSha : null,
|
|
149
149
|
blocker: childOutcome && childOutcome.blocker !== undefined ? childOutcome.blocker : null,
|
|
150
|
+
deferredPrs: childOutcome && Array.isArray(childOutcome.deferredPrs) ? childOutcome.deferredPrs : [],
|
|
150
151
|
}
|
|
151
152
|
}),
|
|
152
153
|
)
|
|
@@ -161,17 +162,20 @@ const results = childResults.map((eachResult, eachIndex) =>
|
|
|
161
162
|
rounds: null,
|
|
162
163
|
finalSha: null,
|
|
163
164
|
blocker: 'child run threw or was skipped before returning an outcome',
|
|
165
|
+
deferredPrs: [],
|
|
164
166
|
}
|
|
165
167
|
: eachResult,
|
|
166
168
|
)
|
|
167
169
|
|
|
168
170
|
const convergedCount = results.filter((eachResult) => eachResult.converged).length
|
|
169
|
-
|
|
171
|
+
const allDeferredPrs = results.flatMap((eachResult) => eachResult.deferredPrs)
|
|
172
|
+
log(`autoconverge multi-PR done: ${convergedCount}/${results.length} PR(s) converged, ${allDeferredPrs.length} deferred hardening PR(s) opened`)
|
|
170
173
|
|
|
171
174
|
return {
|
|
172
175
|
converged: convergedCount === results.length,
|
|
173
176
|
prCount: results.length,
|
|
174
177
|
convergedCount,
|
|
175
178
|
results,
|
|
179
|
+
allDeferredPrs,
|
|
176
180
|
blocker: null,
|
|
177
181
|
}
|
|
@@ -21,6 +21,19 @@ const productionModule = new Function(
|
|
|
21
21
|
)();
|
|
22
22
|
const { normalizeMultiInput, classifyMultiInput, childRunInput } = productionModule;
|
|
23
23
|
|
|
24
|
+
const AsyncFunction = async function () {}.constructor;
|
|
25
|
+
const workflowBodySource = multiSource.slice(multiSource.indexOf('function normalizeMultiInput('));
|
|
26
|
+
const runMultiWorkflowBody = new AsyncFunction(
|
|
27
|
+
'args',
|
|
28
|
+
'phase',
|
|
29
|
+
'log',
|
|
30
|
+
'parallel',
|
|
31
|
+
'workflow',
|
|
32
|
+
workflowBodySource,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
function ignoreCall() {}
|
|
36
|
+
|
|
24
37
|
const SCRIPT_PATH = '/abs/skills/autoconverge/workflow/converge.mjs';
|
|
25
38
|
|
|
26
39
|
function validEntry(prNumber) {
|
|
@@ -124,3 +137,16 @@ test('childRunInput forwards bugbotDisabled true when the entry opts out', () =>
|
|
|
124
137
|
test('childRunInput defaults bugbotDisabled to false when the entry omits it', () => {
|
|
125
138
|
assert.equal(childRunInput(validEntry(398)).bugbotDisabled, false);
|
|
126
139
|
});
|
|
140
|
+
|
|
141
|
+
test('the malformed-input blocker return carries an empty allDeferredPrs list', async () => {
|
|
142
|
+
const blockerOutcome = await runMultiWorkflowBody(
|
|
143
|
+
'not json at all',
|
|
144
|
+
ignoreCall,
|
|
145
|
+
ignoreCall,
|
|
146
|
+
ignoreCall,
|
|
147
|
+
ignoreCall,
|
|
148
|
+
);
|
|
149
|
+
assert.notEqual(blockerOutcome.blocker, null);
|
|
150
|
+
assert.equal(blockerOutcome.prCount, 0);
|
|
151
|
+
assert.deepEqual(blockerOutcome.allDeferredPrs, []);
|
|
152
|
+
});
|