Преглед на файлове

chore: enforce unified GitHub labels

Tianyi Cui преди 4 седмици
родител
ревизия
a9abb62195
променени са 4 файла, в които са добавени 132 реда и са изтрити 19 реда
  1. 3 3
      .github/dependabot.yml
  2. 41 2
      .github/issue-management/policy.mjs
  3. 87 13
      .github/issue-management/policy.test.mjs
  4. 1 1
      AGENTS.md

+ 3 - 3
.github/dependabot.yml

@@ -13,7 +13,7 @@ updates:
     cooldown:
       default-days: 30
     labels:
-      - "cleanup"
+      - "kind/dependency"
       - "area/infra"
 
   - package-ecosystem: "uv"
@@ -25,7 +25,7 @@ updates:
     cooldown:
       default-days: 30
     labels:
-      - "cleanup"
+      - "kind/dependency"
       - "area/infra"
 
   - package-ecosystem: "github-actions"
@@ -37,5 +37,5 @@ updates:
     cooldown:
       default-days: 30
     labels:
-      - "cleanup"
+      - "kind/dependency"
       - "area/infra"

+ 41 - 2
.github/issue-management/policy.mjs

@@ -12,6 +12,27 @@ const AUDIT_MARKER = '<!-- dsh-issue-policy -->'
 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']
+const PR_KINDS = new Set([
+  'kind/feature',
+  'kind/bug-fix',
+  'kind/doc',
+  'kind/testing',
+  'kind/cleanup',
+  'kind/dependency',
+])
+const LEGACY_LABELS = new Set([
+  'kind/bug',
+  'kind/documentation',
+  '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))
 
@@ -224,8 +245,14 @@ export function retainIssueReferences(references, issues) {
 export function validateIssue(issue) {
   const errors = validateBody(issue)
   const status = issue.status
+  const invalidLabels = issue.labels.filter(
+    (label) => label.startsWith('kind/') || LEGACY_LABELS.has(label),
+  )
 
   if (!/\p{Script=Han}/u.test(issue.title)) errors.push('Issue 标题必须包含中文')
+  if (invalidLabels.length > 0) {
+    errors.push(`Issue 不得使用 PR kind 或旧版标签:${invalidLabels.join(', ')}`)
+  }
   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,
@@ -261,12 +288,24 @@ export function validateIssue(issue) {
 export function validatePullRequest(input) {
   if (!requiresPullRequestPolicy(input)) return []
   const errors = []
-  const kinds = input.labels.filter((label) => label.startsWith('kind/'))
+  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 (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) {

+ 87 - 13
.github/issue-management/policy.test.mjs

@@ -27,6 +27,25 @@ const legalIssue = {
   stateReason: null,
 }
 
+const canonicalKinds = [
+  'kind/feature',
+  'kind/bug-fix',
+  'kind/doc',
+  'kind/testing',
+  'kind/cleanup',
+  'kind/dependency',
+]
+
+const reviewedPull = (labels) => ({
+  isDraft: false,
+  authorType: 'User',
+  reviewRequestCount: 1,
+  reviewCount: 0,
+  labels,
+  references: { all: [2], resolving: [], related: [2] },
+  issues: new Map([[2, { priority: null }]]),
+})
+
 test('counts only text outside details', () => {
   assert.deepEqual(countVisibleUnits('支持 GitHub Project。<details>隐藏文字</details>'), {
     units: 4,
@@ -92,6 +111,32 @@ test('rejects metadata prefixes in an Issue title', () => {
   assert.ok(errors.includes('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀'))
 })
 
+test('reserves PR kind and legacy labels for pull requests', () => {
+  for (const label of [
+    ...canonicalKinds,
+    'kind/experimental',
+    'kind/bug',
+    'kind/documentation',
+    'bug-fix',
+    'doc',
+    'cleanup',
+    'testing',
+    'dependencies',
+    'ci',
+    'cli',
+    'llm',
+    'web-search',
+  ]) {
+    assert.ok(
+      validateIssue({ ...legalIssue, labels: [label] }).some((error) =>
+        error.startsWith('Issue 不得使用 PR kind 或旧版标签:'),
+      ),
+      label,
+    )
+  }
+  assert.deepEqual(validateIssue({ ...legalIssue, labels: ['area/web', 'source/member'] }), [])
+})
+
 test('keeps terminal Status aligned with the native close reason', () => {
   assert.deepEqual(
     validateIssue({ ...legalIssue, status: 'Done', state: 'closed', stateReason: 'completed' }),
@@ -260,22 +305,51 @@ test('requires repository PR labels in the enforcement scope', () => {
     references: { all: [2], resolving: [], related: [2] },
     issues: new Map([[2, { priority: null }]]),
   })
-  assert.ok(errors.includes('PR 必须恰好有一个 kind/*,当前为 0'))
+  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('accepts exactly the canonical kinds with extensible areas', () => {
+  for (const kind of canonicalKinds) {
+    assert.deepEqual(validatePullRequest(reviewedPull([kind, 'area/future-domain'])), [], kind)
+  }
+})
+
+test('rejects multiple, unknown, legacy, and Issue-source PR labels', () => {
+  assert.ok(
+    validatePullRequest(
+      reviewedPull(['kind/feature', 'kind/doc', 'area/web']),
+    ).includes('PR 必须恰好有一个允许的 kind/*,当前为 2'),
+  )
+  assert.ok(
+    validatePullRequest(reviewedPull(['kind/experimental', 'area/web'])).includes(
+      'PR 含不支持的 kind/*:kind/experimental',
+    ),
+  )
+  for (const label of [
+    'kind/bug',
+    'kind/documentation',
+    'bug-fix',
+    'doc',
+    'cleanup',
+    'testing',
+    'dependencies',
+    'ci',
+    'cli',
+    'llm',
+    'web-search',
+  ]) {
+    assert.ok(
+      validatePullRequest(reviewedPull(['kind/feature', 'area/web', label])).some((error) =>
+        error.startsWith('PR 含旧版标签:'),
+      ),
+      label,
+    )
+  }
+  assert.ok(
+    validatePullRequest(
+      reviewedPull(['kind/feature', 'area/web', 'source/internal-pr']),
+    ).includes('source/* 仅用于 Issue:source/internal-pr'),
   )
 })
 

+ 1 - 1
AGENTS.md

@@ -118,7 +118,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
 - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
 - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up.
 - **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)).
-- **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([workflow](.agents/skills/dsh-labeling/SKILL.md)).
+- **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md)).
 - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
 - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it.