| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760 |
- #!/usr/bin/env node
- import fs from 'node:fs'
- import process from 'node:process'
- import { pathToFileURL } from 'node:url'
- import config from './config.json' with { type: 'json' }
- const API_VERSION = '2026-03-10'
- const AUDIT_MARKER = '<!-- dsh-issue-policy -->'
- const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task'])
- const PRIORITIES = ['p0', 'p1', 'p2', 'p3']
- const PR_KINDS = new Set([
- 'kind/feature',
- 'kind/bug-fix',
- 'kind/doc',
- 'kind/testing',
- 'kind/cleanup',
- 'kind/dependency',
- ])
- // Retired label aliases stay reserved so they cannot be recreated.
- const LEGACY_LABELS = new Set([
- 'kind/bug',
- 'kind/documentation',
- 'feature',
- 'bug-fix',
- 'doc',
- 'cleanup',
- 'testing',
- 'dependencies',
- 'ci',
- 'cli',
- 'llm',
- 'web-search',
- ])
- 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 未设置')
- }
- if (typeof config.priorityField !== 'string' || !config.priorityField) {
- throw new Error('config.priorityField 未设置')
- }
- if (typeof config.startDateField !== 'string' || !config.startDateField) {
- throw new Error('config.startDateField 未设置')
- }
- if (typeof config.projectTimeZone !== 'string' || !config.projectTimeZone) {
- throw new Error('config.projectTimeZone 未设置')
- }
- Intl.DateTimeFormat('en-US', { timeZone: config.projectTimeZone })
- /**
- * Decide whether the human-review policy applies to a PR.
- * @param {{isDraft: boolean, authorType: string, reviewRequestCount: number, reviewCount: number}} input PR state.
- * @returns {boolean} Whether the PR policy is mandatory.
- */
- export function requiresPullRequestPolicy({
- isDraft,
- authorType,
- reviewRequestCount,
- reviewCount,
- }) {
- const automated = authorType === 'Bot' || authorType === 'App'
- return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0)
- }
- /**
- * 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 {'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
- }
- /**
- * Convert a GitHub timestamp to a Project date in one configured time zone.
- * @param {string} timestamp ISO timestamp.
- * @param {string} timeZone IANA time-zone name.
- * @returns {string} Calendar date in YYYY-MM-DD form.
- */
- export function projectDate(timestamp, timeZone = config.projectTimeZone) {
- const instant = new Date(timestamp)
- if (Number.isNaN(instant.getTime())) throw new Error(`无效的 PR 创建时间:${timestamp}`)
- const parts = Object.fromEntries(
- new Intl.DateTimeFormat('en-US', {
- timeZone,
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- })
- .formatToParts(instant)
- .filter((part) => part.type !== 'literal')
- .map((part) => [part.type, part.value]),
- )
- return `${parts.year}-${parts.month}-${parts.day}`
- }
- function stripIgnoredMarkdown(body) {
- const lines = body.replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)
- const kept = []
- let fence = null
- for (const line of lines) {
- const marker = line.match(/^\s*([\u0060~]{3,})/)
- if (marker) {
- if (fence === null) fence = marker[1][0]
- else if (marker[1][0] === fence) fence = null
- continue
- }
- if (fence === null) kept.push(line)
- }
- return kept.join('\n').replace(/\u0060[^\u0060]*\u0060/g, ' ')
- }
- /**
- * Parse same-repository resolving and informational references.
- * @param {{body: string, repository: string}} input PR body and repository.
- * @returns {{all: number[], resolving: number[], related: number[]}} References.
- */
- export function parseReferences({ body, repository }) {
- const source = stripIgnoredMarkdown(body)
- const expected = repository.toLowerCase()
- const all = new Set()
- const resolving = new Set()
- const reference =
- /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+)/gi
- const closing =
- /\b(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?)\s*:?\s+(?:(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+))/gi
- for (const match of source.matchAll(reference)) {
- const explicit = (match[1] ?? match[3] ?? '').toLowerCase()
- const number = Number(match[2] ?? match[4])
- if (!explicit || explicit === expected) all.add(number)
- }
- for (const match of source.matchAll(closing)) {
- const explicit = (match[1] ?? match[3] ?? '').toLowerCase()
- const number = Number(match[2] ?? match[4])
- if (!explicit || explicit === expected) {
- all.add(number)
- resolving.add(number)
- }
- }
- return {
- all: [...all].sort((left, right) => left - right),
- resolving: [...resolving].sort((left, right) => left - right),
- related: [...all].filter((number) => !resolving.has(number)).sort((a, b) => a - b),
- }
- }
- /**
- * Retain only references that resolve to Issues rather than pull requests.
- * @param {{all: number[], resolving: number[], related: number[]}} references Parsed references.
- * @param {Map<number, unknown>} issues Resolved same-repository Issues.
- * @returns {{all: number[], resolving: number[], related: number[]}} Issue-only references.
- */
- export function retainIssueReferences(references, issues) {
- return {
- all: references.all.filter((number) => issues.has(number)),
- resolving: references.resolving.filter((number) => issues.has(number)),
- related: references.related.filter((number) => issues.has(number)),
- }
- }
- /**
- * Validate one Issue with its Project status.
- * @param {{labels: string[], type: string|null, priority: string|null, status: string|null, state: string, stateReason: string|null}} issue Issue snapshot.
- * @returns {string[]} Validation errors.
- */
- export function validateIssue(issue) {
- const errors = []
- const status = issue.status
- const invalidLabels = issue.labels.filter(isInvalidIssueLabel)
- if (invalidLabels.length > 0) {
- errors.push(`Issue 不得使用 PR kind 或旧版标签:${invalidLabels.join(', ')}`)
- }
- if (!TYPES.has(issue.type ?? '')) errors.push('Type 必须是五种原生英文 Type 之一')
- if (!status || !config.statuses.includes(status)) errors.push('Issue 必须在 Project 中且具有合法 Status')
- if (issue.priority !== null && !PRIORITIES.includes(issue.priority.toLowerCase())) {
- errors.push('Priority 必须为空或为 P0–P3')
- }
- if (status === 'Done' && (issue.state !== 'closed' || issue.stateReason !== 'completed')) {
- errors.push('Done 必须对应 Completed 关闭原因')
- }
- if (
- status === 'No action' &&
- (issue.state !== 'closed' || issue.stateReason !== 'not_planned')
- ) {
- errors.push('No action 必须对应 Not planned 关闭原因')
- }
- if (!['Done', 'No action'].includes(status ?? '') && issue.state !== 'open') {
- errors.push(`${status} 必须对应开放 Issue`)
- }
- return errors
- }
- function isInvalidIssueLabel(label) {
- return label.startsWith('kind/') || LEGACY_LABELS.has(label)
- }
- /**
- * Validate PR metadata and its referenced Issues.
- * @param {{authorType: string, labels: string[], references: ReturnType<typeof parseReferences>, issues: Map<number, {priority: string|null}>}} input PR snapshot.
- * @returns {string[]} Validation errors.
- */
- export function validatePullRequest(input) {
- if (!requiresPullRequestPolicy(input)) return []
- const errors = []
- const kinds = input.labels.filter((label) => PR_KINDS.has(label))
- const unknownKinds = input.labels.filter(
- (label) => label.startsWith('kind/') && !PR_KINDS.has(label) && !LEGACY_LABELS.has(label),
- )
- const legacyLabels = input.labels.filter((label) => LEGACY_LABELS.has(label))
- const sourceLabels = input.labels.filter((label) => label.startsWith('source/'))
- const priorities = input.labels.filter((label) => PRIORITIES.includes(label))
- const areas = input.labels.filter((label) => label.startsWith('area/'))
- if (input.references.all.length === 0) errors.push('PR 正文必须引用至少一个同仓库 Issue')
- if (kinds.length !== 1) {
- errors.push(`PR 必须恰好有一个允许的 kind/*,当前为 ${kinds.length}`)
- }
- if (unknownKinds.length > 0) {
- errors.push(`PR 含不支持的 kind/*:${unknownKinds.join(', ')}`)
- }
- if (legacyLabels.length > 0) errors.push(`PR 含旧版标签:${legacyLabels.join(', ')}`)
- if (sourceLabels.length > 0) errors.push(`source/* 仅用于 Issue:${sourceLabels.join(', ')}`)
- if (priorities.length > 1) errors.push(`PR 最多有一个 p0–p3,当前为 ${priorities.length}`)
- if (areas.length === 0) errors.push('PR 必须至少有一个 area/*')
- for (const number of input.references.all) {
- if (!input.issues.has(number)) errors.push(`#${number} 不是同仓库 Issue`)
- }
- const resolving = input.references.resolving
- .map((number) => [number, input.issues.get(number)])
- .filter((entry) => entry[1])
- if (resolving.length === 0) return errors
- const issuePriorities = resolving
- .map(([, issue]) => issue.priority?.toLowerCase())
- .filter((priority) => PRIORITIES.includes(priority))
- if (priorities.length === 0 && issuePriorities.length > 0) {
- const highest = issuePriorities.sort(
- (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right),
- )[0]
- errors.push(`PR Priority 应为 ${highest}`)
- } else if (priorities.length === 1 && issuePriorities.length !== resolving.length) {
- errors.push('有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority')
- } else if (priorities.length === 1) {
- const highest = issuePriorities.sort(
- (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right),
- )[0]
- if (priorities[0] !== highest) errors.push(`PR Priority 应为 ${highest}`)
- }
- return errors
- }
- function token() {
- const value = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
- if (!value) throw new Error('GH_TOKEN 或 GITHUB_TOKEN 未设置')
- return value
- }
- function projectToken() {
- return process.env.PROJECT_TOKEN || token()
- }
- async function api(path, options = {}) {
- const { allow404 = false, ...requestOptions } = options
- const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, {
- ...requestOptions,
- headers: {
- Accept: 'application/vnd.github+json',
- Authorization: `Bearer ${token()}`,
- 'X-GitHub-Api-Version': API_VERSION,
- 'User-Agent': 'dsh-issue-policy',
- ...options.headers,
- },
- })
- if (allow404 && response.status === 404) return null
- if (!response.ok) {
- const body = await response.text()
- throw new Error(`${requestOptions.method ?? 'GET'} ${path}: ${response.status} ${body}`)
- }
- if (response.status === 204) return null
- return response.json()
- }
- async function graphql(query, variables) {
- const result = await api('/graphql', {
- method: 'POST',
- body: JSON.stringify({ query, variables }),
- headers: {
- Authorization: `Bearer ${projectToken()}`,
- 'Content-Type': 'application/json',
- },
- })
- if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; '))
- return result.data
- }
- /**
- * Read one Issue together with its Project planning values.
- * @param {number} number Same-repository Issue number.
- * @param {string|null|undefined} status Optional known Project status.
- * @returns {Promise<object|null>} Issue snapshot, or null when the number identifies a pull request.
- */
- export async function issueSnapshot(number, status = undefined) {
- const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`)
- if (issue.pull_request) return null
- const context = await projectContext(number)
- return {
- number,
- nodeId: issue.node_id,
- labels: issue.labels.map((label) => label.name),
- type: issue.type?.name ?? null,
- priority: context.item?.priorityValue?.name ?? null,
- status: status === undefined ? (context.item?.fieldValueByName?.name ?? null) : status,
- state: issue.state,
- stateReason: issue.state_reason ?? null,
- }
- }
- async function projectContext(number, includeStatusActor = false, includeStartDate = false) {
- const data = await graphql(
- `query(
- $organization: String!
- $repository: String!
- $number: Int!
- $project: Int!
- $includeStatusActor: Boolean!
- $includeStartDate: Boolean!
- $priorityField: String!
- $startDateField: String!
- ) {
- organization(login: $organization) {
- projectV2(number: $project) {
- id
- title
- fields(first: 50) {
- nodes {
- ... on ProjectV2Field {
- id
- name
- dataType
- isIssueField
- }
- ... on ProjectV2SingleSelectField {
- id
- name
- dataType
- isIssueField
- options { id name }
- }
- }
- }
- }
- }
- 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
- project { id }
- fieldValueByName(name: "Status") {
- ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
- }
- priorityValue: fieldValueByName(name: $priorityField) {
- ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
- }
- startDateValue: fieldValueByName(name: $startDateField)
- @include(if: $includeStartDate) {
- ... on ProjectV2ItemFieldDateValue { date }
- }
- }
- }
- }
- }
- }`,
- {
- organization: config.organization,
- repository: config.repository,
- number,
- project: config.projectNumber,
- includeStatusActor,
- includeStartDate,
- priorityField: config.priorityField,
- startDateField: config.startDateField,
- },
- )
- const project = data.organization?.projectV2
- const issue = data.repository?.issue
- if (!project || project.title !== config.projectTitle) throw new Error('目标 Project 不存在或标题不匹配')
- if (!issue) throw new Error(`#${number} 不存在`)
- const statusField = project.fields.nodes.find((field) => field?.name === 'Status')
- if (!statusField) throw new Error('Project 缺少 Status 字段')
- const priorityField = project.fields.nodes.find((field) => field?.name === config.priorityField)
- if (!priorityField) throw new Error(`Project 缺少 ${config.priorityField} 字段`)
- if (priorityField.dataType !== 'SINGLE_SELECT') {
- throw new Error(`Project ${config.priorityField} 字段必须为 Single Select`)
- }
- if (priorityField.isIssueField) {
- throw new Error(`Project ${config.priorityField} 字段必须为 Project custom field`)
- }
- const startDateField = includeStartDate
- ? project.fields.nodes.find((field) => field?.name === config.startDateField)
- : null
- if (includeStartDate && !startDateField) {
- throw new Error(`Project 缺少 ${config.startDateField} 字段`)
- }
- if (startDateField && startDateField.dataType !== 'DATE') {
- throw new Error(`Project ${config.startDateField} 字段必须为 Date`)
- }
- if (startDateField?.isIssueField) {
- throw new Error(`Project ${config.startDateField} 字段必须为 Project Date 字段`)
- }
- const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id)
- const latestStatusEvent = issue.timelineItems?.nodes
- ?.filter((event) => event?.project?.id === project.id)
- .at(-1)
- const statusActor =
- latestStatusEvent && latestStatusEvent.status === item?.fieldValueByName?.name
- ? (latestStatusEvent.actor?.login ?? null)
- : null
- return { project, issue, statusField, priorityField, startDateField, item, statusActor }
- }
- async function ensureProjectItem(number, includeStartDate = false) {
- const context = await projectContext(number, false, includeStartDate)
- if (context.item) return context
- const data = await graphql(
- `mutation($projectId: ID!, $contentId: ID!) {
- addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
- item { id }
- }
- }`,
- { projectId: context.project.id, contentId: context.issue.id },
- )
- return {
- ...context,
- item: {
- id: data.addProjectV2ItemById.item.id,
- fieldValueByName: null,
- priorityValue: null,
- startDateValue: null,
- },
- }
- }
- /**
- * Initialize one Issue's Project Start Date when it is empty.
- * @param {number} number Same-repository Issue number.
- * @param {string} date Date in YYYY-MM-DD form.
- * @returns {Promise<void>} Resolves after the conditional Project update.
- */
- export async function initializeIssueStartDate(number, date) {
- const context = await ensureProjectItem(number, true)
- if (context.item.startDateValue?.date) return
- await graphql(
- `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
- updateProjectV2ItemFieldValue(input: {
- projectId: $projectId,
- itemId: $itemId,
- fieldId: $fieldId,
- value: {date: $date}
- }) { projectV2Item { id } }
- }`,
- {
- projectId: context.project.id,
- itemId: context.item.id,
- fieldId: context.startDateField.id,
- date,
- },
- )
- }
- /**
- * Initialize every referenced Issue from a newly opened PR.
- * @param {{createdAt: string, references: {all: number[]}}} pull Pull-request snapshot.
- * @param {string} action Pull-request event action.
- * @param {(number: number, date: string) => Promise<void>} initialize Date writer.
- * @returns {Promise<void>} Resolves after all eligible Issues are processed.
- */
- export async function initializePullRequestStartDates(
- pull,
- action,
- initialize = initializeIssueStartDate,
- ) {
- if (action !== 'opened') return
- const date = projectDate(pull.createdAt)
- for (const number of pull.references.all) await initialize(number, date)
- }
- 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
- await graphql(
- `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
- updateProjectV2ItemFieldValue(input: {
- projectId: $projectId,
- itemId: $itemId,
- fieldId: $fieldId,
- value: {singleSelectOptionId: $optionId}
- }) { projectV2Item { id } }
- }`,
- {
- projectId: context.project.id,
- itemId: context.item.id,
- fieldId: context.statusField.id,
- optionId: option.id,
- },
- )
- }
- async function setStatus(number, status) {
- await updateStatus(await ensureProjectItem(number), status)
- }
- /**
- * Remove pull-request kinds and retired aliases from one Issue snapshot.
- * @param {{number: number, labels: string[]}} issue Issue snapshot.
- * @returns {Promise<object>} Snapshot containing only labels that remain on the Issue.
- */
- export async function repairIssueLabels(issue) {
- const invalidLabels = issue.labels.filter(isInvalidIssueLabel)
- for (const label of invalidLabels) {
- await api(
- `/repos/${config.organization}/${config.repository}/issues/${issue.number}/labels/${encodeURIComponent(label)}`,
- { method: 'DELETE', allow404: true },
- )
- }
- return {
- ...issue,
- labels: issue.labels.filter((label) => !isInvalidIssueLabel(label)),
- }
- }
- async function upsertAudit(number, errors) {
- const comments = await api(
- `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`,
- )
- const existing = comments.find(
- (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AUDIT_MARKER),
- )
- if (errors.length === 0) {
- if (existing) {
- await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, {
- method: 'DELETE',
- })
- }
- return
- }
- const body = `${AUDIT_MARKER}\n⚠️ Issue policy 未通过:\n\n${errors.map((error) => `- ${error}`).join('\n')}`
- if (existing) {
- if (existing.body === body) return
- await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, {
- method: 'PATCH',
- body: JSON.stringify({ body }),
- headers: { 'Content-Type': 'application/json' },
- })
- } else {
- await api(`/repos/${config.organization}/${config.repository}/issues/${number}/comments`, {
- method: 'POST',
- body: JSON.stringify({ body }),
- headers: { 'Content-Type': 'application/json' },
- })
- }
- }
- /**
- * Repair deterministic Issue metadata violations and publish the remaining audit result.
- * @param {number} number Same-repository Issue number.
- * @param {string[]} extraErrors Errors supplied by the triggering lifecycle operation.
- * @param {string|null|undefined} status Optional known Project status.
- * @returns {Promise<string[]>} Violations that remain after repair.
- */
- export async function auditIssue(number, extraErrors = [], status = undefined) {
- const issue = await issueSnapshot(number, status)
- if (!issue) return []
- const repairedIssue = await repairIssueLabels(issue)
- const errors = [...extraErrors, ...validateIssue(repairedIssue)]
- await upsertAudit(number, errors)
- return errors
- }
- async function resolvingReferencesSnapshot(number, pull) {
- const references = parseReferences({
- body: pull.body ?? '',
- repository: `${config.organization}/${config.repository}`,
- })
- const issues = new Map()
- for (const issueNumber of references.all) {
- const issue = await issueSnapshot(issueNumber, null)
- if (issue) issues.set(issueNumber, issue)
- }
- 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),
- }
- }
- async function lifecyclePullRequestSnapshot(number) {
- const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
- return {
- ...(await resolvingReferencesSnapshot(number, pull)),
- createdAt: pull.created_at,
- }
- }
- async function transitionResolvingIssues(pull, command) {
- for (const number of pull.references.resolving) {
- 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.
- await updateStatus(context, target)
- await auditIssue(number)
- }
- }
- async function runPullRequestCheck(event) {
- const pull = await pullRequestSnapshot(event.pull_request.number)
- const errors = validatePullRequest(pull)
- if (errors.length > 0) {
- for (const error of errors) process.stdout.write(`::error::${error}\n`)
- throw new Error(`Issue policy 未通过,共 ${errors.length} 项`)
- }
- process.stdout.write(
- requiresPullRequestPolicy(pull) ? 'Issue policy 通过。\n' : 'PR 尚未进入 Issue policy 强制范围。\n',
- )
- }
- async function runLifecycle(eventName, event) {
- if (eventName === 'issues') {
- const number = event.issue.number
- if (event.action === 'opened') await setStatus(number, 'Inbox')
- if (event.action === 'closed') {
- const target = event.issue.state_reason === 'not_planned' ? 'No action' : 'Done'
- await setStatus(number, target)
- }
- if (event.action === 'reopened') {
- await setStatus(number, 'Inbox')
- }
- await ensureProjectItem(number)
- await auditIssue(number)
- return
- }
- if (eventName === 'pull_request' || eventName === 'pull_request_review') {
- const command = resolvingIssueStatusCommand(eventName, event)
- if (!command) return
- const pull = await lifecyclePullRequestSnapshot(event.pull_request.number)
- await transitionResolvingIssues(pull, command)
- if (eventName === 'pull_request') {
- await initializePullRequestStartDates(pull, event.action)
- }
- }
- }
- function readEvent() {
- if (!process.env.GITHUB_EVENT_PATH) throw new Error('GITHUB_EVENT_PATH 未设置')
- return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))
- }
- async function main(argv) {
- const [command] = argv
- if (command === 'pr') await runPullRequestCheck(readEvent())
- else if (command === 'lifecycle') await runLifecycle(process.env.GITHUB_EVENT_NAME, readEvent())
- else throw new Error('用法:policy.mjs pr|lifecycle')
- }
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
- main(process.argv.slice(2)).catch((error) => {
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
- process.exitCode = 1
- })
- }
|