|
|
@@ -1,5 +1,7 @@
|
|
|
import assert from 'node:assert/strict'
|
|
|
-import { readFileSync, readdirSync } from 'node:fs'
|
|
|
+import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
|
+import { tmpdir } from 'node:os'
|
|
|
+import { join } from 'node:path'
|
|
|
import test from 'node:test'
|
|
|
|
|
|
import {
|
|
|
@@ -14,6 +16,9 @@ import {
|
|
|
retainIssueReferences,
|
|
|
resolvingIssueStatusCommand,
|
|
|
requiresPullRequestPolicy,
|
|
|
+ runLifecycle,
|
|
|
+ runPullRequestCheck,
|
|
|
+ runPullRequestPreflight,
|
|
|
validateIssue,
|
|
|
validatePullRequest,
|
|
|
} from './policy.mjs'
|
|
|
@@ -636,10 +641,21 @@ test('maps only explicit review handoffs to review status commands', () => {
|
|
|
)
|
|
|
})
|
|
|
|
|
|
-test('keeps ordinary pull request events as forward-only implementation signals', () => {
|
|
|
- for (const action of ['opened', 'edited', 'synchronize', 'reopened', 'labeled', 'unlabeled']) {
|
|
|
+test('keeps PR opening, reopening, and body edits as implementation signals', () => {
|
|
|
+ for (const action of ['opened', 'reopened']) {
|
|
|
assert.equal(resolvingIssueStatusCommand('pull_request', { action }), 'implementation')
|
|
|
}
|
|
|
+ assert.equal(
|
|
|
+ resolvingIssueStatusCommand('pull_request', { action: 'edited', changes: { body: { from: '' } } }),
|
|
|
+ 'implementation',
|
|
|
+ )
|
|
|
+ for (const action of ['synchronize', 'labeled', 'unlabeled', 'edited']) {
|
|
|
+ assert.equal(resolvingIssueStatusCommand('pull_request', { action }), null)
|
|
|
+ }
|
|
|
+ assert.equal(
|
|
|
+ resolvingIssueStatusCommand('pull_request', { action: 'edited', changes: { title: { from: '' } } }),
|
|
|
+ null,
|
|
|
+ )
|
|
|
assert.equal(
|
|
|
resolvingIssueStatusCommand('pull_request', { action: 'review_request_removed' }),
|
|
|
null,
|
|
|
@@ -749,6 +765,169 @@ test('rejects multiple, unknown, legacy, and Issue-source PR labels', () => {
|
|
|
)
|
|
|
})
|
|
|
|
|
|
+const mockPolicyApi = (t, { pull = {}, requested = true, reviews = [], issues = {}, priority = 'P1', projectError = false } = {}) => {
|
|
|
+ const environment = ['GH_TOKEN', 'GITHUB_TOKEN', 'PROJECT_TOKEN', 'GITHUB_API_URL', 'GITHUB_OUTPUT']
|
|
|
+ const previous = new Map(environment.map((key) => [key, process.env[key]]))
|
|
|
+ const directory = mkdtempSync(join(tmpdir(), 'dsh-policy-'))
|
|
|
+ t.after(() => {
|
|
|
+ for (const [key, value] of previous) {
|
|
|
+ if (value === undefined) delete process.env[key]
|
|
|
+ else process.env[key] = value
|
|
|
+ }
|
|
|
+ rmSync(directory, { recursive: true, force: true })
|
|
|
+ })
|
|
|
+ for (const key of environment) delete process.env[key]
|
|
|
+ process.env.GITHUB_TOKEN = 'repository-token'
|
|
|
+ process.env.GITHUB_OUTPUT = join(directory, 'output')
|
|
|
+ const requests = []
|
|
|
+ const output = []
|
|
|
+ t.mock.method(process.stdout, 'write', (text) => { output.push(text); return true })
|
|
|
+ t.mock.method(globalThis, 'fetch', async (url, options) => {
|
|
|
+ const path = new URL(url).pathname + new URL(url).search
|
|
|
+ requests.push(path)
|
|
|
+ if (path.endsWith('/pulls/10')) return Response.json({
|
|
|
+ draft: false, user: { type: 'User' }, body: 'Refs #2',
|
|
|
+ labels: [{ name: 'kind/cleanup' }, { name: 'area/infra' }], ...pull,
|
|
|
+ })
|
|
|
+ if (path.endsWith('/requested_reviewers')) {
|
|
|
+ return Response.json({ users: requested ? [{}] : [], teams: [] })
|
|
|
+ }
|
|
|
+ if (path.endsWith('/reviews?per_page=100')) return Response.json(reviews)
|
|
|
+ if (path === '/graphql') {
|
|
|
+ assert.equal(options.headers.Authorization, 'Bearer repository-token')
|
|
|
+ if (projectError) return Response.json({ errors: [{ message: 'Project access denied' }] })
|
|
|
+ return Response.json({ data: projectGraphqlData({ priority }) })
|
|
|
+ }
|
|
|
+ const number = Number(path.match(/\/issues\/(\d+)$/)?.[1])
|
|
|
+ assert.ok(Object.hasOwn(issues, number), 'Unexpected request: ' + path)
|
|
|
+ const issue = issues[number]
|
|
|
+ return Response.json(issue ?? { message: 'Not Found' }, { status: issue === null ? 404 : 200 })
|
|
|
+ })
|
|
|
+ return { requests, output, workflowOutput: () => readFileSync(process.env.GITHUB_OUTPUT, 'utf8') }
|
|
|
+}
|
|
|
+
|
|
|
+for (const [name, pull, requested, count] of [
|
|
|
+ ['draft', { draft: true }, true, 1],
|
|
|
+ ['Bot', { user: { type: 'Bot' } }, true, 1],
|
|
|
+ ['App', { user: { type: 'App' } }, true, 1],
|
|
|
+ ['not reviewed', {}, false, 3],
|
|
|
+]) {
|
|
|
+ test('reads no Issue or Project for a currently exempt ' + name + ' PR', async (t) => {
|
|
|
+ const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #999', ...pull }, requested })
|
|
|
+ const event = { pull_request: { number: 10, draft: false, user: { type: 'User' } } }
|
|
|
+ assert.deepEqual(await runPullRequestPreflight(event), { eligible: false, needsProject: false })
|
|
|
+ assert.equal(fixture.requests.length, count)
|
|
|
+ assert.equal(fixture.workflowOutput(), 'eligible=false\nexempt=true\nneeds-project=false\n')
|
|
|
+ await runPullRequestCheck(event)
|
|
|
+ assert.equal(fixture.requests.length, count * 2)
|
|
|
+ assert.ok(fixture.output.every((text) => text.includes('Issue policy exempt')))
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+test('validates informational Issues and ignores PR numbers without Project reads', async (t) => {
|
|
|
+ const fixture = mockPolicyApi(t, { pull: { body: 'Refs #2; Fixes #3' }, issues: { 2: {}, 3: { pull_request: {} } } })
|
|
|
+ const event = { pull_request: { number: 10, draft: true, body: 'Fixes #999' } }
|
|
|
+ assert.deepEqual(await runPullRequestPreflight(event), { eligible: true, needsProject: false })
|
|
|
+ assert.equal(fixture.requests.length, 5)
|
|
|
+ assert.equal(fixture.workflowOutput(), 'eligible=true\nexempt=false\nneeds-project=false\n')
|
|
|
+ await runPullRequestCheck(event)
|
|
|
+ assert.equal(fixture.requests.length, 10)
|
|
|
+ assert.ok(!fixture.requests.includes('/graphql'))
|
|
|
+})
|
|
|
+
|
|
|
+test('requires a real Issue and explains why stacked PR references do not qualify', async (t) => {
|
|
|
+ const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #3' }, issues: { 3: { pull_request: {} } } })
|
|
|
+ await assert.rejects(runPullRequestCheck({ pull_request: { number: 10 } }), /Issue policy 未通过/)
|
|
|
+ assert.equal(fixture.requests.length, 4)
|
|
|
+ assert.match(fixture.output.join(''), /PR 编号(包括堆叠依赖 PR)不算 Issue 引用/)
|
|
|
+})
|
|
|
+
|
|
|
+test('fetches Project Priority only for resolving Issues and enforces mismatch', async (t) => {
|
|
|
+ const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #2; Refs #4; Fixes #3' }, issues: { 2: {}, 4: {}, 3: { pull_request: {} } } })
|
|
|
+ const event = { pull_request: { number: 10 } }
|
|
|
+ assert.deepEqual(await runPullRequestPreflight(event), { eligible: true, needsProject: true })
|
|
|
+ assert.equal(fixture.requests.length, 6)
|
|
|
+ assert.equal(fixture.workflowOutput(), 'eligible=true\nexempt=false\nneeds-project=true\n')
|
|
|
+ assert.ok(!fixture.requests.includes('/graphql'))
|
|
|
+ await assert.rejects(runPullRequestCheck(event), /Issue policy 未通过/)
|
|
|
+ assert.equal(fixture.requests.length, 13)
|
|
|
+ assert.equal(fixture.requests.filter((path) => path === '/graphql').length, 1)
|
|
|
+ assert.match(fixture.output.join(''), /PR Priority 应为 p1/)
|
|
|
+})
|
|
|
+
|
|
|
+test('enforces current metadata on title edits and prior reviews without requested reviewers', async (t) => {
|
|
|
+ const fixture = mockPolicyApi(t, { requested: false, reviews: [{}], pull: { labels: [] }, issues: { 2: {} } })
|
|
|
+ await assert.rejects(runPullRequestCheck({ action: 'edited', changes: { title: { from: 'old' } }, pull_request: { number: 10 } }), /Issue policy 未通过/)
|
|
|
+ assert.equal(fixture.requests.length, 4)
|
|
|
+ assert.match(fixture.output.join(''), /PR 必须至少有一个 area/)
|
|
|
+})
|
|
|
+
|
|
|
+test('fails closed on missing referenced numbers and unavailable Project access', async (t) => {
|
|
|
+ const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #2' }, issues: { 2: null } })
|
|
|
+ await assert.rejects(runPullRequestPreflight({ pull_request: { number: 10 } }), /404/)
|
|
|
+ assert.equal(fixture.requests.length, 4)
|
|
|
+})
|
|
|
+
|
|
|
+test('fails closed when current resolving Issues need a Project token preflight did not mint', async (t) => {
|
|
|
+ const pull = { draft: true, body: 'Fixes #2' }
|
|
|
+ const fixture = mockPolicyApi(t, { pull, issues: { 2: {} }, projectError: true })
|
|
|
+ const event = { pull_request: { number: 10 } }
|
|
|
+ assert.deepEqual(await runPullRequestPreflight(event), { eligible: false, needsProject: false })
|
|
|
+ pull.draft = false
|
|
|
+ await assert.rejects(runPullRequestCheck(event), /Project access denied/)
|
|
|
+ assert.equal(fixture.requests.length, 6)
|
|
|
+})
|
|
|
+
|
|
|
+test('performs no lifecycle requests for removed signals or title-only edits', async (t) => {
|
|
|
+ const fixture = mockPolicyApi(t)
|
|
|
+ for (const action of ['synchronize', 'labeled', 'unlabeled']) {
|
|
|
+ await runLifecycle('pull_request', { action, pull_request: { number: 10 } })
|
|
|
+ }
|
|
|
+ await runLifecycle('pull_request', { action: 'edited', changes: { title: { from: '' } }, pull_request: { number: 10 } })
|
|
|
+ for (const state of ['approved', 'commented']) {
|
|
|
+ await runLifecycle('pull_request_review', { action: 'submitted', review: { state }, pull_request: { number: 10 } })
|
|
|
+ }
|
|
|
+ assert.deepEqual(fixture.requests, [])
|
|
|
+})
|
|
|
+
|
|
|
+test('keeps trusted preflight before token minting and required policy unconditional', () => {
|
|
|
+ const source = readFileSync(new URL('../workflows/issue-policy.yml', import.meta.url), 'utf8')
|
|
|
+ const job = source.slice(source.indexOf(' policy:'))
|
|
|
+ assert.ok(job.includes(' name: Issue policy'))
|
|
|
+ assert.ok(!job.slice(0, job.indexOf(' steps:')).includes(' if:'))
|
|
|
+ assert.ok(source.includes('types: [opened, edited, synchronize, reopened, labeled, unlabeled, ready_for_review, review_requested]'))
|
|
|
+ const steps = job.split(' - name: ').slice(1)
|
|
|
+ assert.equal(steps.length, 4)
|
|
|
+ assert.ok(steps[0].includes('ref: ${{ github.event.repository.default_branch }}'))
|
|
|
+ assert.ok(steps[0].includes('persist-credentials: false'))
|
|
|
+ assert.doesNotMatch(source, /pull_request\.head|pull_request_target/)
|
|
|
+ assert.ok(steps[1].includes('id: preflight'))
|
|
|
+ assert.ok(steps[1].includes('GITHUB_TOKEN: ${{ github.token }}'))
|
|
|
+ assert.ok(steps[1].includes('run: node .github/issue-management/policy.mjs pr-preflight'))
|
|
|
+ assert.doesNotMatch(steps[1], /secrets\.|PROJECT_TOKEN|if:/)
|
|
|
+ assert.ok(steps[2].includes("if: ${{ steps.preflight.outputs.needs-project == 'true' }}"))
|
|
|
+ assert.ok(steps[2].includes('permission-organization-projects: read'))
|
|
|
+ assert.ok(steps[3].includes('PROJECT_TOKEN: ${{ steps.app-token.outputs.token }}'))
|
|
|
+ assert.ok(steps[3].includes('run: node .github/issue-management/policy.mjs pr'))
|
|
|
+ assert.ok(!steps[3].includes('if:'))
|
|
|
+})
|
|
|
+
|
|
|
+test('allocates lifecycle runners only for relevant reviews and PR body edits', () => {
|
|
|
+ const source = readFileSync(new URL('../workflows/issue-lifecycle.yml', import.meta.url), 'utf8')
|
|
|
+ const issues = source.split(' issues:')[1].split(' pull_request:')[0]
|
|
|
+ const pulls = source.split(' pull_request:')[1].split(' pull_request_review:')[0]
|
|
|
+ const actions = (block) => [...block.matchAll(/^ - (\w+)$/gm)].map((match) => match[1])
|
|
|
+ assert.deepEqual(actions(issues), ['opened', 'edited', 'labeled', 'unlabeled', 'closed', 'reopened', 'typed', 'untyped', 'field_added', 'field_removed'])
|
|
|
+ assert.deepEqual(actions(pulls), ['opened', 'edited', 'reopened', 'review_requested'])
|
|
|
+ const job = source.slice(source.indexOf(' lifecycle:'))
|
|
|
+ const beforeSteps = job.slice(0, job.indexOf(' steps:'))
|
|
|
+ assert.ok(beforeSteps.includes(' if: >-'))
|
|
|
+ assert.ok(beforeSteps.includes("(github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested') &&"))
|
|
|
+ assert.ok(beforeSteps.includes("(github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.body != null)"))
|
|
|
+ assert.ok(source.includes('ref: ${{ github.event.repository.default_branch }}'))
|
|
|
+ assert.ok(source.includes('persist-credentials: false'))
|
|
|
+})
|
|
|
+
|
|
|
test('allows missing Priority only when resolving Issues are also unprioritized', () => {
|
|
|
const pull = {
|
|
|
isDraft: false,
|