diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000000..9427dc1d52 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,22 @@ +--- +name: Bug +about: 记录现有预期行为的失效 +title: '' +labels: '' +assignees: '' +type: Bug +--- + + +一句话说明错误结果。 + +
+复现、预期与验收 + +- 复现步骤: +- 实际结果: +- 预期结果: +- 环境: +- 验收条件: + +
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..8005e32267 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: false +contact_links: [] diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000000..2c65f5544d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,20 @@ +--- +name: Feature +about: 新增或有意改变可观察行为 +title: '' +labels: '' +assignees: '' +type: Feature +--- + + +一句话说明预期结果。 + +
+验收与细节 + +- 验收条件: +- 用户或模型可见变化: +- 测试证据: + +
diff --git a/.github/ISSUE_TEMPLATE/idea.md b/.github/ISSUE_TEMPLATE/idea.md new file mode 100644 index 0000000000..c8bf80402d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/idea.md @@ -0,0 +1,20 @@ +--- +name: Idea +about: 记录尚未承诺实施、但具有行动可能的想法 +title: '' +labels: '' +assignees: '' +type: Idea +--- + + +一句话说明价值假设。 + +
+价值与细节 + +- 价值假设: +- 需要验证: +- 可能的后续工作: + +
diff --git a/.github/ISSUE_TEMPLATE/research.md b/.github/ISSUE_TEMPLATE/research.md new file mode 100644 index 0000000000..8acfe2cc6e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/research.md @@ -0,0 +1,21 @@ +--- +name: Research +about: 形成结论、证据或决策 +title: '' +labels: '' +assignees: '' +type: Research +--- + + +一句话说明待回答的问题。 + +
+问题与证据标准 + +- 核心问题: +- 证据标准: +- 交付结论: +- 可能的后续工作: + +
diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000000..376855c27c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.md @@ -0,0 +1,20 @@ +--- +name: Task +about: 明确的非 Feature、非 Bug 工作 +title: '' +labels: '' +assignees: '' +type: Task +--- + + +一句话说明要完成的工作。 + +
+验收与细节 + +- 验收条件: +- 交付物: +- 测试证据: + +
diff --git a/.github/issue-management/config.json b/.github/issue-management/config.json new file mode 100644 index 0000000000..41019f0aa2 --- /dev/null +++ b/.github/issue-management/config.json @@ -0,0 +1,17 @@ +{ + "organization": "deepseek-harness", + "repository": "deepseek-harness", + "projectNumber": 1, + "projectTitle": "DSH Issue Management", + "priorityField": "Priority", + "allowUnassignedOwner": true, + "statuses": [ + "Inbox", + "Backlog", + "Ready", + "In progress", + "In review", + "Done", + "No action" + ] +} diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs new file mode 100644 index 0000000000..bc8c881eda --- /dev/null +++ b/.github/issue-management/policy.mjs @@ -0,0 +1,545 @@ +#!/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 BODY_LIMIT = 50 +const AUDIT_MARKER = '' +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'] + +/** + * Return Markdown outside balanced details elements. + * @param {string} body Markdown body. + * @returns {{text: string, balanced: boolean, detailsCount: number, allCollapsed: boolean}} Visible source and details shape. + */ +export function extractOutsideDetails(body) { + const source = body.replace(//g, '') + const tag = /<\/?details\b[^>]*>/gi + let depth = 0 + let cursor = 0 + let balanced = true + let text = '' + let detailsCount = 0 + let allCollapsed = true + + for (const match of source.matchAll(tag)) { + const index = match.index ?? 0 + if (depth === 0) text += source.slice(cursor, index) + if (/^<\//.test(match[0])) { + if (depth === 0) balanced = false + else depth -= 1 + } else { + depth += 1 + detailsCount += 1 + if (/\sopen(?:\s|=|>)/i.test(match[0])) allCollapsed = false + } + cursor = index + match[0].length + } + + if (depth === 0) text += source.slice(cursor) + if (depth !== 0) balanced = false + return { text, balanced, detailsCount, allCollapsed } +} + +/** + * Count Chinese characters and contiguous Latin, numeric, or code tokens. + * @param {string} body Markdown body. + * @returns {{units: number, balanced: boolean, detailsCount: number, allCollapsed: boolean}} Visible unit count and details shape. + */ +export function countVisibleUnits(body) { + const outside = extractOutsideDetails(body) + const visible = outside.text + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1') + .replace(/<((?:https?:\/\/|mailto:)[^>]+)>/gi, '$1') + .replace(/<[^>]+>/g, ' ') + .replace(/&(?:[A-Za-z]+|#\d+|#x[0-9A-Fa-f]+);/g, ' ') + .replace(/[\u0060*~\[\]{}()<>#!|]/g, ' ') + const han = visible.match(/\p{Script=Han}/gu)?.length ?? 0 + const tokens = visible.match(/[\p{Script=Latin}\p{Number}_./:@+-]+/gu)?.length ?? 0 + return { + units: han + tokens, + balanced: outside.balanced, + detailsCount: outside.detailsCount, + allCollapsed: outside.allCollapsed, + } +} + +function firstNonblankLine(body) { + return body + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) +} + +/** + * Validate body shape and Owner against assignees. + * @param {{body: string, assignees: string[], allowUnassignedOwner?: boolean}} input Body input. + * @returns {string[]} Validation errors. + */ +export function validateBody({ + body, + assignees, + allowUnassignedOwner = config.allowUnassignedOwner ?? false, +}) { + const errors = [] + const count = countVisibleUnits(body) + const owner = firstNonblankLine(body)?.match(OWNER_LINE)?.[1] ?? null + const normalized = [...new Set(assignees.map((login) => login.toLowerCase()))] + + if (!count.balanced) errors.push('details 标签必须成对闭合') + if (count.detailsCount === 0) errors.push('正文必须包含默认收起的
区域') + if (!count.allCollapsed) errors.push('details 必须默认收起,不得设置 open') + if (count.units > BODY_LIMIT) { + errors.push(`正文外露部分为 ${count.units} 单位,超过 50 单位`) + } + if (normalized.length >= 2 && !owner) { + errors.push('多个 Assignees 时首个非空行必须是 Owner: @login') + } else if (normalized.length >= 2 && !normalized.includes(owner.toLowerCase())) { + errors.push('Owner 必须属于 Assignees') + } else if ( + normalized.length < 2 && + owner && + !(normalized.length === 0 && allowUnassignedOwner) + ) { + errors.push('零或一个 Assignee 时不得写 Owner 行') + } + return errors +} + +/** + * Decide whether a PR has entered the human-review enforcement boundary. + * @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) +} + +function stripIgnoredMarkdown(body) { + const lines = body.replace(//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), + } +} + +/** + * Validate one Issue with its Project status. + * @param {{title: string, body: string, assignees: string[], 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 = validateBody(issue) + const status = issue.status + + if (!/\p{Script=Han}/u.test(issue.title)) errors.push('Issue 标题必须包含中文') + if ( + /^\s*(?:\[(?:Idea|Feature|Bug|Research|Task|P[0-3]|Inbox|Backlog|Ready|In progress|In review|Done|No action|Owner|area\/[^\]]+)[^\]]*\]|(?:Idea|Feature|Bug|Research|Task|P[0-3]|Inbox|Backlog|Ready|In progress|In review|Done|No action|Owner|area\/[^:: ]+)\s*[::-])/iu.test( + issue.title, + ) + ) { + errors.push('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀') + } + 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 +} + +/** + * Validate PR metadata and its referenced Issues. + * @param {{authorType: string, labels: string[], references: ReturnType, issues: Map}} input PR snapshot. + * @returns {string[]} Validation errors. + */ +export function validatePullRequest(input) { + if (!requiresPullRequestPolicy(input)) return [] + const errors = [] + const kinds = input.labels.filter((label) => label.startsWith('kind/')) + 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 (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 +} + +async function api(path, options = {}) { + const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, { + ...options, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token()}`, + 'X-GitHub-Api-Version': API_VERSION, + 'User-Agent': 'dsh-issue-policy', + ...options.headers, + }, + }) + if (options.allow404 && response.status === 404) return null + if (!response.ok) { + const body = await response.text() + throw new Error(`${options.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: { 'Content-Type': 'application/json' }, + }) + if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; ')) + return result.data +} + +async function issueSnapshot(number, status = undefined) { + const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`) + if (issue.pull_request) return null + const values = await api( + `/repos/${config.organization}/${config.repository}/issues/${number}/issue-field-values?per_page=100`, + ) + const field = (name) => values.find((value) => value.issue_field_name === name) + return { + number, + nodeId: issue.node_id, + title: issue.title, + body: issue.body ?? '', + assignees: issue.assignees.map((assignee) => assignee.login), + labels: issue.labels.map((label) => label.name), + type: issue.type?.name ?? null, + priority: field(config.priorityField)?.single_select_option?.name ?? null, + status: status === undefined ? await projectStatus(number) : status, + state: issue.state, + stateReason: issue.state_reason ?? null, + } +} + +async function projectContext(number) { + const data = await graphql( + `query($organization: String!, $repository: String!, $number: Int!, $project: Int!) { + organization(login: $organization) { + projectV2(number: $project) { + id + title + fields(first: 50) { + nodes { + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + repository(owner: $organization, name: $repository) { + issue(number: $number) { + id + projectItems(first: 20, includeArchived: true) { + nodes { + id + project { id } + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name optionId } + } + } + } + } + } + }`, + { + organization: config.organization, + repository: config.repository, + number, + project: config.projectNumber, + }, + ) + 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 item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id) + return { project, issue, statusField, item } +} + +async function projectStatus(number) { + const context = await projectContext(number) + return context.item?.fieldValueByName?.name ?? null +} + +async function ensureProjectItem(number) { + const context = await projectContext(number) + 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 }, + } +} + +async function setStatus(number, status) { + const context = await ensureProjectItem(number) + 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 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' }, + }) + } +} + +async function auditIssue(number, extraErrors = [], status = undefined) { + const issue = await issueSnapshot(number, status) + if (!issue) return [] + const errors = [...extraErrors, ...validateIssue(issue)] + await upsertAudit(number, errors) + 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`), + ]) + 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, + 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, + issues, + } +} + +async function moveResolvingIssues(pull, from, to) { + for (const number of pull.references.resolving) { + const current = await issueSnapshot(number) + if (!current || current.status !== from) continue + await setStatus(number, to) + 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 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') + } + } +} + +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 + }) +} diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs new file mode 100644 index 0000000000..890247db29 --- /dev/null +++ b/.github/issue-management/policy.test.mjs @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + countVisibleUnits, + parseReferences, + requiresPullRequestPolicy, + validateBody, + validateIssue, + validatePullRequest, +} from './policy.mjs' + +const withDetails = (summary) => + `${summary}\n\n
验收与细节待补充。
` + +const legalIssue = { + title: '完成议题管理校验', + body: withDetails('完成议题管理校验。'), + assignees: [], + labels: [], + type: 'Idea', + priority: null, + status: 'In review', + state: 'open', + stateReason: null, +} + +test('counts only text outside details', () => { + assert.deepEqual(countVisibleUnits('支持 GitHub Project。
隐藏文字
'), { + units: 4, + balanced: true, + detailsCount: 1, + allCollapsed: true, + }) +}) + +test('requires a balanced default-collapsed details region', () => { + assert.deepEqual(validateBody({ body: '完成工作。', assignees: [] }), [ + '正文必须包含默认收起的
区域', + ]) + assert.deepEqual( + validateBody({ + body: '完成工作。\n\n
细节待补充。
', + assignees: [], + }), + ['details 必须默认收起,不得设置 open'], + ) + assert.deepEqual( + validateBody({ body: '完成工作。\n\n
细节', assignees: [] }), + ['details 标签必须成对闭合'], + ) +}) + +test('requires Owner for multiple assignees', () => { + assert.deepEqual( + validateBody({ + body: withDetails('完成工作。'), + assignees: ['tianyicui', 'tianyicui-bot'], + }), + ['多个 Assignees 时首个非空行必须是 Owner: @login'], + ) +}) + +test('accepts an intended Owner while assignment permission is pending', () => { + assert.deepEqual( + validateBody({ + body: withDetails('Owner: @octocat\n\n完成工作。'), + assignees: [], + }), + [], + ) + assert.deepEqual( + validateBody({ + body: withDetails('Owner: @octocat\n\n完成工作。'), + assignees: ['hubot'], + }), + ['零或一个 Assignee 时不得写 Owner 行'], + ) +}) + +test('allows optional metadata in every open Status', () => { + assert.deepEqual(validateIssue(legalIssue), []) + for (const status of ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review']) { + assert.deepEqual(validateIssue({ ...legalIssue, status }), []) + } +}) + +test('rejects metadata prefixes in an Issue title', () => { + const errors = validateIssue({ ...legalIssue, title: '[Bug] 修复恢复错误' }) + assert.ok(errors.includes('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀')) +}) + +test('keeps terminal Status aligned with the native close reason', () => { + assert.deepEqual( + validateIssue({ ...legalIssue, status: 'Done', state: 'closed', stateReason: 'completed' }), + [], + ) + assert.deepEqual( + validateIssue({ + ...legalIssue, + status: 'No action', + state: 'closed', + stateReason: 'not_planned', + }), + [], + ) + assert.ok(validateIssue({ ...legalIssue, status: 'Done' }).includes('Done 必须对应 Completed 关闭原因')) +}) + +test('separates resolving and informational references', () => { + assert.deepEqual( + parseReferences({ + body: 'Fixes #12\nRelated to #4\nRefs deepseekharness/dsh-test#7', + repository: 'deepseekharness/dsh-test', + }), + { all: [4, 7, 12], resolving: [12], related: [4, 7] }, + ) +}) + +test('allows informational references without cross-object constraints', () => { + const errors = validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/cleanup', 'area/infra'], + references: { all: [4], resolving: [], related: [4] }, + issues: new Map([[4, { type: 'Bug', priority: 'P0', labels: ['area/web'] }]]), + }) + assert.deepEqual(errors, []) +}) + +test('enforces highest resolving Priority without Type or area synchronization', () => { + const pull = { + isDraft: false, + authorType: 'User', + reviewRequestCount: 0, + reviewCount: 1, + labels: ['kind/cleanup', 'p0', 'area/web'], + references: { all: [2, 3], resolving: [2, 3], related: [] }, + issues: new Map([ + [2, { type: 'Feature', priority: 'P2', labels: ['area/web'] }], + [3, { type: 'Bug', priority: 'P0', labels: ['area/session'] }], + ]), + } + assert.deepEqual(validatePullRequest(pull), []) + assert.ok( + validatePullRequest({ ...pull, labels: ['kind/cleanup', 'p2', 'area/web'] }).includes( + 'PR Priority 应为 p0', + ), + ) +}) + +test('requires policy only after a human PR enters review', () => { + assert.equal( + requiresPullRequestPolicy({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + }), + true, + ) + assert.equal( + requiresPullRequestPolicy({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 0, + reviewCount: 0, + }), + false, + ) +}) + +test('exempts Draft, Bot, and App PRs', () => { + const invalid = { + isDraft: false, + labels: [], + references: { all: [], resolving: [], related: [] }, + issues: new Map(), + reviewRequestCount: 1, + reviewCount: 0, + } + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'Bot' }), []) + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'App' }), []) + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'User', isDraft: true }), []) + assert.ok(validatePullRequest({ ...invalid, authorType: 'User' }).length > 0) +}) + +test('requires repository PR labels in the enforcement scope', () => { + const errors = validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: [], + references: { all: [2], resolving: [], related: [2] }, + issues: new Map([[2, { priority: null }]]), + }) + assert.ok(errors.includes('PR 必须恰好有一个 kind/*,当前为 0')) + assert.ok(errors.includes('PR 必须至少有一个 area/*')) +}) + +test('accepts repository-extensible kind labels', () => { + assert.deepEqual( + validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/dependency', 'area/infra'], + references: { all: [2], resolving: [], related: [2] }, + issues: new Map([[2, { priority: null }]]), + }), + [], + ) +}) + +test('allows missing Priority only when resolving Issues are also unprioritized', () => { + const pull = { + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/feature', 'area/web'], + references: { all: [2], resolving: [2], related: [] }, + issues: new Map([[2, { priority: null }]]), + } + assert.deepEqual(validatePullRequest(pull), []) + assert.ok( + validatePullRequest({ ...pull, issues: new Map([[2, { priority: 'P2' }]]) }).includes( + 'PR Priority 应为 p2', + ), + ) + assert.ok( + validatePullRequest({ ...pull, labels: [...pull.labels, 'p2'] }).includes( + '有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority', + ), + ) +}) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..e960016ae8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ + + + + +关联 Issue: + +
+变更与验证 + +- 变更: +- 验证: + +