feat: direct issue status from PR review events

This commit is contained in:
Tianyi Cui
2026-08-10 23:13:32 +08:00
parent 4a9d72818f
commit 00708b950b
16 changed files with 313 additions and 57 deletions
+111 -28
View File
@@ -37,10 +37,21 @@ const LEGACY_LABELS = new Set([
])
const TERMINAL_STATUSES = new Set(['Done', 'No action'])
const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status))
const IMPLEMENTATION_PULL_REQUEST_ACTIONS = new Set([
'opened',
'edited',
'synchronize',
'reopened',
'labeled',
'unlabeled',
])
for (const status of ['In progress', 'In review']) {
if (!ACTIVE_STATUS_ORDER.includes(status)) throw new Error(`config.statuses 缺少 ${status}`)
}
if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) {
throw new Error('config.lifecycleActor 未设置')
}
/**
* Return Markdown outside balanced details elements.
@@ -159,18 +170,48 @@ export function requiresPullRequestPolicy({
}
/**
* 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.
* Translate a repository event into one resolving-Issue lifecycle command.
* @param {string} eventName GitHub event name.
* @param {{action?: string, review?: {state?: string}}} event GitHub event payload.
* @returns {'implementation'|'review-requested'|'changes-requested'|null} Lifecycle command.
*/
export function nextResolvingIssueStatus(currentStatus, pull) {
const target =
!pull.isDraft && (pull.reviewRequestCount > 0 || pull.reviewCount > 0)
? 'In review'
: 'In progress'
export function resolvingIssueStatusCommand(eventName, event) {
if (eventName === 'pull_request') {
if (event.action === 'review_requested') return 'review-requested'
return IMPLEMENTATION_PULL_REQUEST_ACTIONS.has(event.action) ? 'implementation' : null
}
if (
eventName === 'pull_request_review' &&
event.action === 'submitted' &&
event.review?.state?.toLowerCase() === 'changes_requested'
) {
return 'changes-requested'
}
return null
}
/**
* Plan one event-directed resolving-Issue status transition.
* @param {string|null} currentStatus Current Project status.
* @param {'implementation'|'review-requested'|'changes-requested'} command Lifecycle command.
* @param {string|null} currentStatusActor Actor that last set the current Project status.
* @returns {string|null} Status to write, or null when no permitted transition exists.
*/
export function nextResolvingIssueStatus(currentStatus, command, currentStatusActor = null) {
let target
if (command === 'review-requested') target = 'In review'
else if (command === 'implementation' || command === 'changes-requested') target = 'In progress'
else throw new Error(`未知 lifecycle command${command}`)
const currentIndex = ACTIVE_STATUS_ORDER.indexOf(currentStatus)
const targetIndex = ACTIVE_STATUS_ORDER.indexOf(target)
if (
command === 'changes-requested' &&
currentStatus === 'In review' &&
currentStatusActor === config.lifecycleActor
) {
return target
}
return currentIndex >= 0 && currentIndex < targetIndex ? target : null
}
@@ -396,9 +437,15 @@ async function issueSnapshot(number, status = undefined) {
}
}
async function projectContext(number) {
async function projectContext(number, includeStatusActor = false) {
const data = await graphql(
`query($organization: String!, $repository: String!, $number: Int!, $project: Int!) {
`query(
$organization: String!
$repository: String!
$number: Int!
$project: Int!
$includeStatusActor: Boolean!
) {
organization(login: $organization) {
projectV2(number: $project) {
id
@@ -413,6 +460,16 @@ async function projectContext(number) {
repository(owner: $organization, name: $repository) {
issue(number: $number) {
id
timelineItems(last: 100, itemTypes: [PROJECT_V2_ITEM_STATUS_CHANGED_EVENT])
@include(if: $includeStatusActor) {
nodes {
... on ProjectV2ItemStatusChangedEvent {
actor { login }
project { id }
status
}
}
}
projectItems(first: 20, includeArchived: true) {
nodes {
id
@@ -430,6 +487,7 @@ async function projectContext(number) {
repository: config.repository,
number,
project: config.projectNumber,
includeStatusActor,
},
)
const project = data.organization?.projectV2
@@ -439,7 +497,14 @@ async function projectContext(number) {
const statusField = project.fields.nodes.find((field) => field?.name === 'Status')
if (!statusField) throw new Error('Project 缺少 Status 字段')
const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id)
return { project, issue, statusField, item }
const latestStatusEvent = issue.timelineItems?.nodes
?.filter((event) => event?.project?.id === project.id)
.at(-1)
const statusActor =
latestStatusEvent?.status === item?.fieldValueByName?.name
? (latestStatusEvent.actor?.login ?? null)
: null
return { project, issue, statusField, item, statusActor }
}
async function projectStatus(number) {
@@ -530,12 +595,7 @@ async function auditIssue(number, extraErrors = [], status = undefined) {
return errors
}
async function pullRequestSnapshot(number) {
const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
const [reviewRequests, reviews] = await Promise.all([
api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`),
api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`),
])
async function resolvingReferencesSnapshot(number, pull) {
const references = parseReferences({
body: pull.body ?? '',
repository: `${config.organization}/${config.repository}`,
@@ -547,20 +607,41 @@ async function pullRequestSnapshot(number) {
}
return {
number,
isDraft: pull.draft,
authorType: pull.user?.type ?? 'User',
reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length,
reviewCount: reviews.length,
labels: pull.labels.map((label) => label.name),
references: retainIssueReferences(references, issues),
issues,
}
}
async function advanceResolvingIssues(pull) {
async function pullRequestSnapshot(number) {
const [pull, reviewRequests, reviews] = await Promise.all([
api(`/repos/${config.organization}/${config.repository}/pulls/${number}`),
api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`),
api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`),
])
const resolving = await resolvingReferencesSnapshot(number, pull)
return {
...resolving,
isDraft: pull.draft,
authorType: pull.user?.type ?? 'User',
reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length,
reviewCount: reviews.length,
labels: pull.labels.map((label) => label.name),
}
}
async function lifecyclePullRequestSnapshot(number) {
const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
return resolvingReferencesSnapshot(number, pull)
}
async function transitionResolvingIssues(pull, command) {
for (const number of pull.references.resolving) {
const context = await projectContext(number)
const target = nextResolvingIssueStatus(context.item?.fieldValueByName?.name ?? null, pull)
const context = await projectContext(number, command === 'changes-requested')
const target = nextResolvingIssueStatus(
context.item?.fieldValueByName?.name ?? null,
command,
context.statusActor,
)
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.
@@ -598,8 +679,10 @@ async function runLifecycle(eventName, event) {
}
if (eventName === 'pull_request' || eventName === 'pull_request_review') {
const pull = await pullRequestSnapshot(event.pull_request.number)
await advanceResolvingIssues(pull)
const command = resolvingIssueStatusCommand(eventName, event)
if (!command) return
const pull = await lifecyclePullRequestSnapshot(event.pull_request.number)
await transitionResolvingIssues(pull, command)
}
}