Files
vercel__next.js/.github/scripts/next-maintainer-auto-close.js
Marcos Hernanz 0eb4d7d585 Add Next Maintainer auto-close workflow (#98197)
## Summary

- add a manually dispatchable workflow; the hourly schedule is left
commented until initial production verification
- keep the workflow YAML small and place delivery in
`.github/scripts/next-maintainer-auto-close.js` for normal code review
- use a short-lived exact-audience GitHub Actions OIDC token with no
long-lived secret
- trust the authenticated queue contract instead of reimplementing its
Zod validation in the workflow
- post the verifier-authored comment and close with GitHub native
completed, not planned, or duplicate state reasons
- retain one invisible marker only to prevent duplicate public comments
across retries

If an issue is open with the marker, the workflow leaves it open. This
intentionally lets a human reopen win and avoids timeline
reconstruction.

## Permissions

The job grants only `contents: read`, `id-token: write`, and `issues:
write`; every other permission remains none. Both GitHub actions are
pinned to full commit SHAs. Checkout is sparse to the one trusted
JavaScript file and has credential persistence disabled.

## Verification

- mocked delivery harness passes ten scenarios: empty queue, completed,
not planned, duplicate, pull-request rejection, already-closed recovery,
independent close, open marker, transferred issue, and transient failure
- `node --check`, Prettier, and ESLint pass for the extracted
implementation
- Vercel Agent Review, Vercel Security Review, Socket Security, workflow
change detection, and documentation validation pass
- after merge, dispatch the registered workflow on `canary` and add the
production run link here before enabling the hourly schedule

## Dependency

This is the narrow GitHub write-side companion to
vercel-labs/next-maintainer-agent#541, which is deployed.
vercel-labs/next-maintainer-agent#546 further reduces the queue DTO and
changes the delivery limit to 25 first claims per rolling week; this
workflow is compatible with both DTO versions.
2026-09-03 10:32:40 -07:00

156 lines
4.6 KiB
JavaScript

async function deliverOneVerifiedClose({ core, github }) {
const queue =
'https://next-maintainer-agent.vercel.tools/eve/agents/close-verifier/eve/v1/auto-close'
const repository = 'vercel/next.js'
const [owner, repo] = repository.split('/')
async function queueRequest(path, body) {
const token = await core.getIDToken('next-maintainer-auto-close')
core.setSecret(token)
const response = await fetch(`${queue}${path}`, {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'x-vercel-trusted-oidc-idp-token': token,
...(body === undefined ? {} : { 'content-type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
redirect: 'error',
signal: AbortSignal.timeout(15_000),
})
if (response.status === 204) return null
const text = await response.text()
if (!response.ok)
throw new Error(
`Queue returned ${response.status}: ${text.slice(0, 300)}`
)
return text.length === 0 ? null : JSON.parse(text)
}
async function report(claim, outcome, error) {
await queueRequest(`/${encodeURIComponent(claim.approvalId)}/delivery`, {
leaseToken: claim.leaseToken,
outcome,
...(error === undefined ? {} : { error: error.slice(0, 2_000) }),
})
}
async function reportStale(claim, message) {
core.warning(message)
await report(claim, 'stale', message)
}
async function readIssue(number) {
try {
const issue = (
await github.rest.issues.get({ owner, repo, issue_number: number })
).data
if (
issue.number !== number ||
issue.repository_url !== `https://api.github.com/repos/${repository}`
) {
return null
}
return issue
} catch (error) {
if (error?.status === 404 || error?.status === 410) return null
throw error
}
}
const claimed = await queueRequest('/claim')
if (claimed === null) return
const claim = claimed
const marker = `<!-- next-maintainer-auto-close:${claim.approvalId} -->`
try {
const issue = await readIssue(claim.issueNumber)
if (issue === null) {
await reportStale(claim, 'Issue no longer exists in this repository.')
return
}
if (issue.pull_request !== undefined) {
await reportStale(claim, 'Target is a pull request.')
return
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: claim.issueNumber,
per_page: 100,
})
const markerComment = comments.find((comment) =>
(comment.body ?? '').includes(marker)
)
if (issue.state === 'closed') {
if (markerComment === undefined) {
await reportStale(claim, 'Issue was closed independently.')
} else {
await report(claim, 'closed')
}
return
}
if (markerComment !== undefined) {
// This is either a partial prior run or a human reopen. Leave it open.
await reportStale(claim, 'Issue is open after a prior delivery comment.')
return
}
let duplicateIssueId
if (claim.stateReason === 'duplicate') {
if (claim.duplicateIssueNumber === claim.issueNumber) {
await reportStale(claim, 'Issue cannot be a duplicate of itself.')
return
}
const canonical = await readIssue(claim.duplicateIssueNumber)
if (canonical === null || canonical.pull_request !== undefined) {
await reportStale(
claim,
'Duplicate target is not an issue in this repository.'
)
return
}
duplicateIssueId = canonical.id
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: claim.issueNumber,
body: `${claim.closeComment}\n\n${marker}`,
})
const updated = (
await github.rest.issues.update({
owner,
repo,
issue_number: claim.issueNumber,
state: 'closed',
state_reason: claim.stateReason,
...(duplicateIssueId === undefined
? {}
: { duplicate_issue_id: duplicateIssueId }),
})
).data
if (
updated.state !== 'closed' ||
updated.state_reason !== claim.stateReason
) {
throw new Error('GitHub did not apply the requested close reason.')
}
await report(claim, 'closed')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
try {
await report(claim, 'retry', message)
} catch (callbackError) {
core.warning(`Could not report retry: ${callbackError}`)
}
throw error
}
}
module.exports = { deliverOneVerifiedClose }