Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!-- pr-title:loose -->') || 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'
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions openspec/changes/update-agent-pr-guardrails/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Design: PR title guardrails and auto-close

## Overview
将 PR 标题规范与 Issue 关闭规则内化到 agent 工具链:默认严格校验,自动修正,必要时使用 `--loose` 放宽。

## Behavior
- **Strict (default):**
- PR 标题必须匹配 conventional commit 格式。
- 若不符合,自动生成并覆盖:`feat(<type>): <issue title>`。
- PR body 必须包含 `Closes #<issueId>`。
- **Loose (`--loose`):**
- 不阻断提交,但仍尝试修正 PR body 的 `Closes #ID`。

## Title Derivation
- `<type>` 来源:`type/<type>` label(fallback 为 `hybrid`)。
- `<issue title>` 来源:Issue 标题。

## CI Lint
- 增加 PR title lint(默认 strict),从 CI 阶段防止不规范 PR 被合并。

## Scope
- 仅作用于 `pnpm agent task start/submit` 与 PR 创建/更新。
17 changes: 17 additions & 0 deletions openspec/changes/update-agent-pr-guardrails/proposal.md
Original file line number Diff line number Diff line change
@@ -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 逻辑)
Original file line number Diff line number Diff line change
@@ -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(<type>): <issue title>`
- **AND** rejects submission if strict mode is enabled and normalization fails

### Requirement: Auto-close linked issues
The system SHALL ensure PR bodies include `Closes #<issueId>`.

#### 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
10 changes: 10 additions & 0 deletions openspec/changes/update-agent-pr-guardrails/tasks.md
Original file line number Diff line number Diff line change
@@ -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 #<issue>` 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
28 changes: 28 additions & 0 deletions scripts/agent-flow/mcps/git-workflow.mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
135 changes: 116 additions & 19 deletions scripts/agent-flow/workflows/task.workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = "<!-- pr-title:loose -->";

async function syncEnvFiles(root: string, worktreePath: string): Promise<string[]> {
const copied: string[] = [];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
},
});

Expand Down Expand Up @@ -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
// =============================================================================
Expand All @@ -437,6 +533,7 @@ export const workflow = createRouter({
['task start --list-labels', "列出所有可用标签"],
['task sync "- [x] Step 1"', "同步进度"],
["task submit", "提交任务"],
["task submit --loose", "宽松模式提交任务"],
],
});

Expand Down
Loading