mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
chore: add Issue management runtime
This commit is contained in:
22
.github/ISSUE_TEMPLATE/bug.md
vendored
Normal file
22
.github/ISSUE_TEMPLATE/bug.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: Bug
|
||||||
|
about: 记录现有预期行为的失效
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
type: Bug
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||||
|
一句话说明错误结果。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>复现、预期与验收</summary>
|
||||||
|
|
||||||
|
- 复现步骤:
|
||||||
|
- 实际结果:
|
||||||
|
- 预期结果:
|
||||||
|
- 环境:
|
||||||
|
- 验收条件:
|
||||||
|
|
||||||
|
</details>
|
||||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
2
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links: []
|
||||||
20
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Feature
|
||||||
|
about: 新增或有意改变可观察行为
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
type: Feature
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||||
|
一句话说明预期结果。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>验收与细节</summary>
|
||||||
|
|
||||||
|
- 验收条件:
|
||||||
|
- 用户或模型可见变化:
|
||||||
|
- 测试证据:
|
||||||
|
|
||||||
|
</details>
|
||||||
20
.github/ISSUE_TEMPLATE/idea.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/idea.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Idea
|
||||||
|
about: 记录尚未承诺实施、但具有行动可能的想法
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
type: Idea
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||||
|
一句话说明价值假设。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>价值与细节</summary>
|
||||||
|
|
||||||
|
- 价值假设:
|
||||||
|
- 需要验证:
|
||||||
|
- 可能的后续工作:
|
||||||
|
|
||||||
|
</details>
|
||||||
21
.github/ISSUE_TEMPLATE/research.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/research.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: Research
|
||||||
|
about: 形成结论、证据或决策
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
type: Research
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||||
|
一句话说明待回答的问题。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>问题与证据标准</summary>
|
||||||
|
|
||||||
|
- 核心问题:
|
||||||
|
- 证据标准:
|
||||||
|
- 交付结论:
|
||||||
|
- 可能的后续工作:
|
||||||
|
|
||||||
|
</details>
|
||||||
20
.github/ISSUE_TEMPLATE/task.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/task.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Task
|
||||||
|
about: 明确的非 Feature、非 Bug 工作
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
type: Task
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- 标题写中文行动或结果句;外露正文不超过 50 单位。 -->
|
||||||
|
一句话说明要完成的工作。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>验收与细节</summary>
|
||||||
|
|
||||||
|
- 验收条件:
|
||||||
|
- 交付物:
|
||||||
|
- 测试证据:
|
||||||
|
|
||||||
|
</details>
|
||||||
17
.github/issue-management/config.json
vendored
Normal file
17
.github/issue-management/config.json
vendored
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
545
.github/issue-management/policy.mjs
vendored
Normal file
545
.github/issue-management/policy.mjs
vendored
Normal file
@@ -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 = '<!-- 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']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(/<!--[\s\S]*?-->/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('正文必须包含默认收起的 <details> 区域')
|
||||||
|
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(/<!--[\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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<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) => 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
|
||||||
|
})
|
||||||
|
}
|
||||||
240
.github/issue-management/policy.test.mjs
vendored
Normal file
240
.github/issue-management/policy.test.mjs
vendored
Normal file
@@ -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<details><summary>验收与细节</summary>待补充。</details>`
|
||||||
|
|
||||||
|
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。<details>隐藏文字</details>'), {
|
||||||
|
units: 4,
|
||||||
|
balanced: true,
|
||||||
|
detailsCount: 1,
|
||||||
|
allCollapsed: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requires a balanced default-collapsed details region', () => {
|
||||||
|
assert.deepEqual(validateBody({ body: '完成工作。', assignees: [] }), [
|
||||||
|
'正文必须包含默认收起的 <details> 区域',
|
||||||
|
])
|
||||||
|
assert.deepEqual(
|
||||||
|
validateBody({
|
||||||
|
body: '完成工作。\n\n<details open><summary>细节</summary>待补充。</details>',
|
||||||
|
assignees: [],
|
||||||
|
}),
|
||||||
|
['details 必须默认收起,不得设置 open'],
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
validateBody({ body: '完成工作。\n\n<details><summary>细节</summary>', 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',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
13
.github/pull_request_template.md
vendored
Normal file
13
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!-- 写 Fixes #NN 表示解决并自动关闭;写 Related to #NN 仅关联。 -->
|
||||||
|
<!-- 进入评审的非 Draft 人类 PR 至少引用一个同仓库 Issue。 -->
|
||||||
|
<!-- 解决型 PR 与 Issue 同步 Priority;解决多个 Issue 时取最高值。 -->
|
||||||
|
|
||||||
|
关联 Issue:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>变更与验证</summary>
|
||||||
|
|
||||||
|
- 变更:
|
||||||
|
- 验证:
|
||||||
|
|
||||||
|
</details>
|
||||||
Reference in New Issue
Block a user