Merge remote-tracking branch 'origin/master' into codex/unify-landlock-release
# Conflicts: # .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml # scripts/check-workspace-constraints.ts
This commit is contained in:
@@ -12,6 +12,12 @@ const AUDIT_MARKER = '<!-- dsh-issue-policy -->'
|
||||
const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/
|
||||
const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task'])
|
||||
const PRIORITIES = ['p0', 'p1', 'p2', 'p3']
|
||||
const TERMINAL_STATUSES = new Set(['Done', 'No action'])
|
||||
const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status))
|
||||
|
||||
for (const status of ['In progress', 'In review']) {
|
||||
if (!ACTIVE_STATUS_ORDER.includes(status)) throw new Error(`config.statuses 缺少 ${status}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Markdown outside balanced details elements.
|
||||
@@ -129,6 +135,22 @@ export function requiresPullRequestPolicy({
|
||||
return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a forward-only Issue status from the current PR phase.
|
||||
* @param {string|null} currentStatus Current Project status.
|
||||
* @param {{isDraft: boolean, reviewRequestCount: number, reviewCount: number}} pull PR phase.
|
||||
* @returns {string|null} Status to write, or null when no forward transition exists.
|
||||
*/
|
||||
export function nextResolvingIssueStatus(currentStatus, pull) {
|
||||
const target =
|
||||
!pull.isDraft && (pull.reviewRequestCount > 0 || pull.reviewCount > 0)
|
||||
? 'In review'
|
||||
: 'In progress'
|
||||
const currentIndex = ACTIVE_STATUS_ORDER.indexOf(currentStatus)
|
||||
const targetIndex = ACTIVE_STATUS_ORDER.indexOf(target)
|
||||
return currentIndex >= 0 && currentIndex < targetIndex ? target : null
|
||||
}
|
||||
|
||||
function stripIgnoredMarkdown(body) {
|
||||
const lines = body.replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)
|
||||
const kept = []
|
||||
@@ -401,8 +423,7 @@ async function ensureProjectItem(number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function setStatus(number, status) {
|
||||
const context = await ensureProjectItem(number)
|
||||
async function updateStatus(context, status) {
|
||||
const option = context.statusField.options.find((candidate) => candidate.name === status)
|
||||
if (!option) throw new Error(`Status 不存在:${status}`)
|
||||
if (context.item.fieldValueByName?.name === status) return
|
||||
@@ -424,6 +445,10 @@ async function setStatus(number, status) {
|
||||
)
|
||||
}
|
||||
|
||||
async function setStatus(number, status) {
|
||||
await updateStatus(await ensureProjectItem(number), status)
|
||||
}
|
||||
|
||||
async function upsertAudit(number, errors) {
|
||||
const comments = await api(
|
||||
`/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`,
|
||||
@@ -491,11 +516,14 @@ async function pullRequestSnapshot(number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function moveResolvingIssues(pull, from, to) {
|
||||
async function advanceResolvingIssues(pull) {
|
||||
for (const number of pull.references.resolving) {
|
||||
const current = await issueSnapshot(number)
|
||||
if (!current || current.status !== from) continue
|
||||
await setStatus(number, to)
|
||||
const context = await projectContext(number)
|
||||
const target = nextResolvingIssueStatus(context.item?.fieldValueByName?.name ?? null, pull)
|
||||
if (!target) continue
|
||||
// TODO: Replace this latest-state guard with per-Issue serialization or a
|
||||
// conditional ProjectV2 update; GraphQL currently has no compare-and-swap.
|
||||
await updateStatus(context, target)
|
||||
await auditIssue(number)
|
||||
}
|
||||
}
|
||||
@@ -530,12 +558,7 @@ async function runLifecycle(eventName, event) {
|
||||
|
||||
if (eventName === 'pull_request' || eventName === 'pull_request_review') {
|
||||
const pull = await pullRequestSnapshot(event.pull_request.number)
|
||||
const errors = validatePullRequest(pull)
|
||||
if (errors.length > 0) return
|
||||
await moveResolvingIssues(pull, 'Ready', 'In progress')
|
||||
if (pull.reviewRequestCount > 0 || pull.reviewCount > 0) {
|
||||
await moveResolvingIssues(pull, 'In progress', 'In review')
|
||||
}
|
||||
await advanceResolvingIssues(pull)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test'
|
||||
|
||||
import {
|
||||
countVisibleUnits,
|
||||
nextResolvingIssueStatus,
|
||||
parseReferences,
|
||||
retainIssueReferences,
|
||||
requiresPullRequestPolicy,
|
||||
@@ -191,6 +192,49 @@ test('requires policy only after a human PR enters review', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('advances resolving Issues to the live PR phase', () => {
|
||||
const draft = { isDraft: true, reviewRequestCount: 1, reviewCount: 4 }
|
||||
const open = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 }
|
||||
const requestedReview = { isDraft: false, reviewRequestCount: 1, reviewCount: 0 }
|
||||
const submittedReview = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 }
|
||||
|
||||
for (const status of ['Inbox', 'Backlog', 'Ready']) {
|
||||
assert.equal(nextResolvingIssueStatus(status, draft), 'In progress')
|
||||
assert.equal(nextResolvingIssueStatus(status, open), 'In progress')
|
||||
assert.equal(nextResolvingIssueStatus(status, requestedReview), 'In review')
|
||||
assert.equal(nextResolvingIssueStatus(status, submittedReview), 'In review')
|
||||
}
|
||||
assert.equal(nextResolvingIssueStatus('In progress', requestedReview), 'In review')
|
||||
assert.equal(nextResolvingIssueStatus('In progress', submittedReview), 'In review')
|
||||
})
|
||||
|
||||
test('never regresses or reopens a resolving Issue', () => {
|
||||
const implementation = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 }
|
||||
const review = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 }
|
||||
|
||||
assert.equal(nextResolvingIssueStatus('In progress', implementation), null)
|
||||
assert.equal(nextResolvingIssueStatus('In review', implementation), null)
|
||||
assert.equal(nextResolvingIssueStatus('In review', review), null)
|
||||
assert.equal(nextResolvingIssueStatus('Done', review), null)
|
||||
assert.equal(nextResolvingIssueStatus('No action', review), null)
|
||||
assert.equal(nextResolvingIssueStatus(null, review), null)
|
||||
})
|
||||
|
||||
test('keeps lifecycle projection independent of PR metadata enforcement', () => {
|
||||
const pull = {
|
||||
isDraft: false,
|
||||
authorType: 'User',
|
||||
reviewRequestCount: 1,
|
||||
reviewCount: 0,
|
||||
labels: [],
|
||||
references: { all: [2], resolving: [2], related: [] },
|
||||
issues: new Map([[2, { priority: null }]]),
|
||||
}
|
||||
|
||||
assert.ok(validatePullRequest(pull).length > 0)
|
||||
assert.equal(nextResolvingIssueStatus('Inbox', pull), 'In review')
|
||||
})
|
||||
|
||||
test('exempts Draft, Bot, and App PRs', () => {
|
||||
const invalid = {
|
||||
isDraft: false,
|
||||
|
||||
@@ -30,7 +30,7 @@ env:
|
||||
|
||||
jobs:
|
||||
|
||||
# FIXME: Re-enable the three hosted serial reference jobs before cutting a release.
|
||||
# TODO(hosted-serial-ci): Re-enable the three hosted serial reference jobs before release.
|
||||
# The self-hosted standby remains active on every master push.
|
||||
|
||||
# Three enterprise jobs isolate coverage, static analysis, and the
|
||||
|
||||
@@ -21,7 +21,6 @@ on:
|
||||
- reopened
|
||||
- labeled
|
||||
- unlabeled
|
||||
- ready_for_review
|
||||
- review_requested
|
||||
pull_request_review:
|
||||
types:
|
||||
|
||||
Reference in New Issue
Block a user