From a41550e6cf65525f8857fd1c21b2b1b2056f4a12 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 29 Jan 2026 19:43:28 +0800 Subject: [PATCH] feat(hybrid): Fast release flow with tag-triggered CD --- .github/workflows/ci.yml | 34 ++++- .../update-agent-pr-guardrails/design.md | 22 +++ .../update-agent-pr-guardrails/proposal.md | 17 +++ .../specs/agent-workflow/spec.md | 28 ++++ .../update-agent-pr-guardrails/tasks.md | 10 ++ scripts/agent-flow/mcps/git-workflow.mcp.ts | 28 ++++ scripts/agent-flow/workflows/task.workflow.ts | 135 +++++++++++++++--- 7 files changed, 254 insertions(+), 20 deletions(-) create mode 100644 openspec/changes/update-agent-pr-guardrails/design.md create mode 100644 openspec/changes/update-agent-pr-guardrails/proposal.md create mode 100644 openspec/changes/update-agent-pr-guardrails/specs/agent-workflow/spec.md create mode 100644 openspec/changes/update-agent-pr-guardrails/tasks.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b7b487f1..db8873b6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,34 @@ env: NODE_VERSION: '24' jobs: + pr-title-lint: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Validate PR title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_LABELS: ${{ toJson(github.event.pull_request.labels) }} + run: | + node - <<'NODE' + const title = process.env.PR_TITLE || ''; + const body = process.env.PR_BODY || ''; + const labels = JSON.parse(process.env.PR_LABELS || '[]').map((l) => l.name); + const loose = body.includes('') || labels.includes('pr/loose'); + const regex = /^(feat|fix|refactor|chore|docs|test|perf|style|build|ci)(\([^)]+\))?: .+/; + if (!regex.test(title)) { + if (loose) { + console.log('PR title is non-standard, but loose mode detected.'); + process.exit(0); + } + console.error(`PR title is invalid: "${title}"`); + console.error('Expected conventional commit style, e.g. "feat(ui): add button"'); + process.exit(1); + } + console.log('PR title OK'); + NODE + # ==================== Self-hosted 快速链路 ==================== ci-fast: if: vars.USE_SELF_HOSTED == 'true' @@ -140,11 +168,15 @@ jobs: # ==================== 聚合 Job(满足分支保护) ==================== checks: if: always() - needs: [checks-fast, checks-standard] + needs: [checks-fast, checks-standard, pr-title-lint] runs-on: ubuntu-latest steps: - name: Check results run: | + if [ "${{ needs.pr-title-lint.result }}" == "failure" ]; then + echo "PR title lint failed" + exit 1 + fi if [ "${{ needs.checks-fast.result }}" == "success" ] || [ "${{ needs.checks-standard.result }}" == "success" ]; then echo "CI passed via $( [ '${{ needs.checks-fast.result }}' == 'success' ] && echo 'fast path' || echo 'standard path' )" exit 0 diff --git a/openspec/changes/update-agent-pr-guardrails/design.md b/openspec/changes/update-agent-pr-guardrails/design.md new file mode 100644 index 000000000..8c39205b6 --- /dev/null +++ b/openspec/changes/update-agent-pr-guardrails/design.md @@ -0,0 +1,22 @@ +# Design: PR title guardrails and auto-close + +## Overview +将 PR 标题规范与 Issue 关闭规则内化到 agent 工具链:默认严格校验,自动修正,必要时使用 `--loose` 放宽。 + +## Behavior +- **Strict (default):** + - PR 标题必须匹配 conventional commit 格式。 + - 若不符合,自动生成并覆盖:`feat(): `。 + - PR body 必须包含 `Closes #`。 +- **Loose (`--loose`):** + - 不阻断提交,但仍尝试修正 PR body 的 `Closes #ID`。 + +## Title Derivation +- `` 来源:`type/` label(fallback 为 `hybrid`)。 +- `` 来源:Issue 标题。 + +## CI Lint +- 增加 PR title lint(默认 strict),从 CI 阶段防止不规范 PR 被合并。 + +## Scope +- 仅作用于 `pnpm agent task start/submit` 与 PR 创建/更新。 diff --git a/openspec/changes/update-agent-pr-guardrails/proposal.md b/openspec/changes/update-agent-pr-guardrails/proposal.md new file mode 100644 index 000000000..1f68e42a7 --- /dev/null +++ b/openspec/changes/update-agent-pr-guardrails/proposal.md @@ -0,0 +1,17 @@ +# Change: Agent PR guardrails (strict by default, loose override) + +## Why +- 当前主分支出现大量泛化 commit(例如 "feat: complete implementation"),根因是工具链默认 PR 标题和提交信息过于宽松。 +- Issue 未自动关闭,主要因为 PR body 缺少 `Closes #ID` 或未强制校验。 + +## What Changes +- **默认严格校验**:PR 标题必须符合 conventional commit 规范。 +- **自动修正**:若不规范,自动生成规范化标题和 PR body(含 `Closes #ID`)。 +- **宽松模式**:提供 `--loose` 允许跳过严格校验(但仍尽力修正)。 + +## Impact +- Affected specs: agent-workflow (new) +- Affected code: + - `scripts/agent-flow/workflows/task.workflow.ts` + - `scripts/agent-flow/mcps/git-workflow.mcp.ts` + - CI (新增 PR title lint 逻辑) diff --git a/openspec/changes/update-agent-pr-guardrails/specs/agent-workflow/spec.md b/openspec/changes/update-agent-pr-guardrails/specs/agent-workflow/spec.md new file mode 100644 index 000000000..0b4b6d10e --- /dev/null +++ b/openspec/changes/update-agent-pr-guardrails/specs/agent-workflow/spec.md @@ -0,0 +1,28 @@ +# agent-workflow Specification (Delta) + +## ADDED Requirements + +### Requirement: Conventional PR titles (strict by default) +The system SHALL enforce conventional commit-style PR titles by default. + +#### Scenario: Auto-normalize PR title +- **GIVEN** a task PR is created or updated +- **WHEN** the title is non-conforming +- **THEN** the system auto-normalizes it to `feat(): ` +- **AND** rejects submission if strict mode is enabled and normalization fails + +### Requirement: Auto-close linked issues +The system SHALL ensure PR bodies include `Closes #`. + +#### Scenario: Issue auto-closure +- **GIVEN** a PR is created for an issue +- **WHEN** the PR is merged to main +- **THEN** the linked issue is auto-closed by GitHub + +### Requirement: Loose mode override +The system SHALL provide a `--loose` mode to bypass strict PR title enforcement. + +#### Scenario: Loose mode accepts non-standard titles +- **GIVEN** a developer runs `task submit --loose` +- **WHEN** the PR title is non-conforming +- **THEN** the workflow proceeds without blocking diff --git a/openspec/changes/update-agent-pr-guardrails/tasks.md b/openspec/changes/update-agent-pr-guardrails/tasks.md new file mode 100644 index 000000000..380c7ed4a --- /dev/null +++ b/openspec/changes/update-agent-pr-guardrails/tasks.md @@ -0,0 +1,10 @@ +## 1. Implementation +- [ ] Add PR title lint (default strict) with `--loose` bypass +- [ ] Normalize PR title/body during `task submit` and `task start` flows +- [ ] Ensure `Closes #` is always present in PR body +- [ ] Auto-derive conventional commit title from type label + issue title + +## 2. Verification +- [ ] `task submit` with default strict validates and fixes title/body +- [ ] `task submit --loose` allows non-standard titles +- [ ] Merged PR auto-closes linked issue diff --git a/scripts/agent-flow/mcps/git-workflow.mcp.ts b/scripts/agent-flow/mcps/git-workflow.mcp.ts index a7e8a2aa8..be8d36a91 100755 --- a/scripts/agent-flow/mcps/git-workflow.mcp.ts +++ b/scripts/agent-flow/mcps/git-workflow.mcp.ts @@ -189,6 +189,34 @@ export async function updateIssue(args: { issueId: string; body?: string; state? return { success: true }; } +export async function getIssueInfo(args: { issueId: string }): Promise<{ title: string; labels: string[] }> { + const { issueId } = args; + const raw = exec(`gh issue view ${issueId} --repo ${REPO} --json title,labels`); + const data = JSON.parse(raw) as { title: string; labels: Array<{ name: string }> }; + return { + title: data.title, + labels: (data.labels || []).map((label) => label.name), + }; +} + +export async function getPrInfo(args: { head?: string }): Promise<{ number: number; title: string; body: string } | null> { + const head = args.head ? `--head "${args.head}"` : ""; + const raw = safeExec(`gh pr list --repo ${REPO} --state open ${head} --json number,title,body --limit 1`); + if (!raw) return null; + const data = JSON.parse(raw) as Array<{ number: number; title: string; body: string }>; + return data.length > 0 ? data[0] : null; +} + +export async function updatePr(args: { prNumber: number; title?: string; body?: string }) { + const { prNumber, title, body } = args; + const flags: string[] = []; + if (title) flags.push(`--title "${title.replace(/"/g, '\\"')}"`); + if (body) flags.push(`--body "${body.replace(/"/g, '\\"')}"`); + if (flags.length === 0) return { success: true }; + exec(`gh pr edit ${prNumber} --repo ${REPO} ${flags.join(" ")}`); + return { success: true }; +} + export async function createPr(args: { title: string; body: string; head: string; base?: string; draft?: boolean; labels?: string[]; createLabels?: boolean }) { const { title, body, head, base = "main", draft = true, labels, createLabels = false } = args; diff --git a/scripts/agent-flow/workflows/task.workflow.ts b/scripts/agent-flow/workflows/task.workflow.ts index cc7bc2ec7..3fd839454 100755 --- a/scripts/agent-flow/workflows/task.workflow.ts +++ b/scripts/agent-flow/workflows/task.workflow.ts @@ -49,6 +49,9 @@ import { createWorktree, pushWorktree, updateIssue, + getIssueInfo, + getPrInfo, + updatePr, getLabels, } from "../mcps/git-workflow.mcp.ts"; import { getRelatedChapters } from "../mcps/whitebook.mcp.ts"; @@ -61,6 +64,9 @@ import { exists } from "jsr:@std/fs"; const WORKTREE_BASE = ".git-worktree"; const ENV_EXCLUDES = new Set([".env.example"]); +const CONVENTIONAL_TITLE = + /^(feat|fix|refactor|chore|docs|test|perf|style|build|ci)(\([^)]+\))?: .+/; +const LOOSE_MARKER = ""; async function syncEnvFiles(root: string, worktreePath: string): Promise { const copied: string[] = []; @@ -279,9 +285,11 @@ const startWorkflow = defineWorkflow({ // 8. 创建 Draft PR console.log("\n5️⃣ 创建 Draft PR..."); + const prTitle = normalizeTitle(title, type); + const prBody = ensureClosesBody(description, issueId); const { url: prUrl } = await createPr({ - title, - body: `Closes #${issueId}\n\n${description}`, + title: prTitle, + body: prBody, head: branch, base: "main", draft: true, @@ -341,29 +349,88 @@ const syncWorkflow = defineWorkflow({ const submitWorkflow = defineWorkflow({ name: "submit", description: "提交任务并触发 CI (Push + Ready PR)", - handler: async () => { - const wt = getCurrentWorktreeInfo(); - if (!wt || !wt.path) { - console.error("❌ 错误: 必须在 worktree 中运行"); - Deno.exit(1); - } + args: { + loose: { + type: "boolean", + description: "宽松模式,跳过 PR 标题严格校验", + required: false, + }, + }, + handler: async (args) => { + try { + const wt = getCurrentWorktreeInfo(); + if (!wt || !wt.path) { + console.error("❌ 错误: 必须在 worktree 中运行"); + Deno.exit(1); + } - console.log("🚀 提交任务...\n"); + const loose = Boolean(args.loose); - // 1. 推送代码 - console.log("1️⃣ 推送代码..."); - await pushWorktree({ - path: wt.path, - message: "feat: complete implementation", // 默认消息,实际应由开发者 commit - }); + console.log("🚀 提交任务...\n"); + + const issueId = wt.issueId; + if (!issueId) { + console.error("❌ 错误: 无法定位 Issue,请确保在 issue worktree 中运行"); + Deno.exit(1); + } + + const issue = await getIssueInfo({ issueId }); + const typeLabel = resolveTypeLabel(issue.labels); + const normalizedTitle = normalizeTitle(issue.title, typeLabel); + const branch = getGitBranchName(); + let pr = branch ? await getPrInfo({ head: branch }) : null; + + const currentTitle = pr?.title || issue.title; + const desiredTitle = loose ? currentTitle : normalizedTitle; + let desiredBody = ensureClosesBody(pr?.body || "", issueId); + if (loose) { + desiredBody = ensureLooseMarker(desiredBody); + } - // 2. 标记 PR 为 Ready - if (wt.issueId) { + // 1. 推送代码 + console.log("1️⃣ 推送代码..."); + await pushWorktree({ + path: wt.path, + message: desiredTitle, + }); + + pr = branch ? await getPrInfo({ head: branch }) : null; + if (!pr && branch) { + const created = await createPr({ + title: desiredTitle, + body: desiredBody, + head: branch, + base: "main", + draft: false, + labels: issue.labels, + }); + console.log(` ✅ PR Created: ${created.url}`); + pr = { number: Number(created.prNumber), title: desiredTitle, body: desiredBody }; + } + + if (pr) { + const updateTitle = !loose && desiredTitle !== pr.title; + const updateBody = desiredBody !== (pr.body || ""); + if (updateTitle || updateBody) { + await updatePr({ + prNumber: pr.number, + title: updateTitle ? desiredTitle : undefined, + body: updateBody ? desiredBody : undefined, + }); + } + } else { + console.warn("⚠️ 未找到 PR,已跳过 PR 标题/描述修正"); + } + + // 2. 标记 PR 为 Ready console.log("\n2️⃣ 更新 PR 状态..."); console.log("⚠️ 提示: 请手动确认 PR 状态或使用 `gh pr ready`"); - } - console.log("\n✨ 提交完成,等待 Review!"); + console.log("\n✨ 提交完成,等待 Review!"); + } catch (error) { + console.error(`❌ 提交失败: ${error instanceof Error ? error.message : String(error)}`); + throw error; + } }, }); @@ -421,6 +488,35 @@ function getGitBranchName(): string | null { } } +function isConventionalTitle(title: string): boolean { + return CONVENTIONAL_TITLE.test(title.trim()); +} + +function normalizeTitle(title: string, type: string): string { + const trimmed = title.trim(); + if (isConventionalTitle(trimmed)) return trimmed; + return `feat(${type}): ${trimmed}`; +} + +function ensureClosesBody(body: string, issueId: string): string { + const normalized = body.trim(); + const closesLine = `Closes #${issueId}`; + if (normalized.includes(closesLine)) return normalized; + if (!normalized) return closesLine; + return `${closesLine}\n\n${normalized}`; +} + +function ensureLooseMarker(body: string): string { + if (body.includes(LOOSE_MARKER)) return body; + return `${body}\n\n${LOOSE_MARKER}`.trim(); +} + +function resolveTypeLabel(labels: string[]): string { + const match = labels.find((label) => label.startsWith("type/")); + if (!match) return "hybrid"; + return match.slice("type/".length) || "hybrid"; +} + // ============================================================================= // Main Router // ============================================================================= @@ -437,6 +533,7 @@ export const workflow = createRouter({ ['task start --list-labels', "列出所有可用标签"], ['task sync "- [x] Step 1"', "同步进度"], ["task submit", "提交任务"], + ["task submit --loose", "宽松模式提交任务"], ], });