|
|
@@ -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.
|
|
|
+ * 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 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 {{isDraft: boolean, reviewRequestCount: number, reviewCount: number}} pull PR phase.
|
|
|
- * @returns {string|null} Status to write, or null when no forward transition exists.
|
|
|
+ * @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, pull) {
|
|
|
- const target =
|
|
|
- !pull.isDraft && (pull.reviewRequestCount > 0 || pull.reviewCount > 0)
|
|
|
- ? 'In review'
|
|
|
- : 'In progress'
|
|
|
+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,
|
|
|
+ references: retainIssueReferences(references, issues),
|
|
|
+ issues,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+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),
|
|
|
- references: retainIssueReferences(references, issues),
|
|
|
- issues,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-async function advanceResolvingIssues(pull) {
|
|
|
+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)
|
|
|
}
|
|
|
}
|
|
|
|