From f6e439e02600af10c657d67b37227dda174c4161 Mon Sep 17 00:00:00 2001 From: legendPei <804141866@qq.com> Date: Sat, 2 May 2026 10:37:49 +0800 Subject: [PATCH 01/60] feat(gitlab): add review workflow foundation --- .../GITLAB_CODE_REVIEW_PLUGIN_DESIGN.md | 1192 +++++++++++++++++ packages/platform-gitlab/README.md | 8 + .../agents/review/pm-coordinator.agent.md | 218 +++ .../01-platform-package-construction.md | 212 +++ .../02-controller-runtime-integration.md | 237 ++++ .../03-validation-and-dry-run.md | 182 +++ .../docs/review-implementation/README.md | 40 + .../fixtures/review/sample-mr-changes.json | 25 + .../fixtures/review/sample-mr-overflow.json | 14 + packages/platform-gitlab/package.json | 6 +- .../platform-gitlab/scripts/review-dry-run.ts | 64 + .../review/gitlab-comment-rendering/SKILL.md | 17 + .../gitlab-commit-review-workflow/SKILL.md | 11 + .../review/gitlab-mr-review-workflow/SKILL.md | 20 + .../skills/review/pm-risk-routing/SKILL.md | 17 + .../review/review-finding-schema/SKILL.md | 24 + .../review/security-review-policy/SKILL.md | 16 + .../skills/review/spec-gate-review/SKILL.md | 15 + .../subagent-prompts/auto-fixer/SKILL.md | 64 + .../subagent-prompts/developer/SKILL.md | 88 ++ .../frontend-designer/SKILL.md | 138 ++ .../review/subagent-prompts/risk-qa/SKILL.md | 137 ++ .../subagent-prompts/security-agent/SKILL.md | 88 ++ .../subagent-prompts/spec-writer/SKILL.md | 89 ++ .../subagent-prompts/tech-architect/SKILL.md | 124 ++ .../review/verification-matrix/SKILL.md | 16 + packages/platform-gitlab/src/index.ts | 1 + .../platform-gitlab/src/review/api-client.ts | 66 + .../src/review/comment-renderer.ts | 49 + .../src/review/context-builder.ts | 68 + .../src/review/diff-builder.ts | 126 ++ .../src/review/event-parser.ts | 126 ++ .../src/review/finding-aggregator.ts | 52 + .../platform-gitlab/src/review/idempotency.ts | 36 + packages/platform-gitlab/src/review/index.ts | 13 + .../src/review/inline-position.ts | 90 ++ .../src/review/output-schema.ts | 98 ++ .../platform-gitlab/src/review/settings.ts | 111 ++ packages/platform-gitlab/src/review/types.ts | 132 ++ .../platform-gitlab/src/review/webhook.ts | 26 + .../platform-gitlab/src/review/workflow.ts | 40 + packages/platform-gitlab/src/runtime.ts | 187 ++- .../test/gitlab-platform.test.ts | 21 + .../test/gitlab-review.test.ts | 171 +++ packages/platform-gitlab/tsconfig.json | 6 +- 45 files changed, 4477 insertions(+), 4 deletions(-) create mode 100644 packages/platform-gitlab/GITLAB_CODE_REVIEW_PLUGIN_DESIGN.md create mode 100644 packages/platform-gitlab/agents/review/pm-coordinator.agent.md create mode 100644 packages/platform-gitlab/docs/review-implementation/01-platform-package-construction.md create mode 100644 packages/platform-gitlab/docs/review-implementation/02-controller-runtime-integration.md create mode 100644 packages/platform-gitlab/docs/review-implementation/03-validation-and-dry-run.md create mode 100644 packages/platform-gitlab/docs/review-implementation/README.md create mode 100644 packages/platform-gitlab/fixtures/review/sample-mr-changes.json create mode 100644 packages/platform-gitlab/fixtures/review/sample-mr-overflow.json create mode 100644 packages/platform-gitlab/scripts/review-dry-run.ts create mode 100644 packages/platform-gitlab/skills/review/gitlab-comment-rendering/SKILL.md create mode 100644 packages/platform-gitlab/skills/review/gitlab-commit-review-workflow/SKILL.md create mode 100644 packages/platform-gitlab/skills/review/gitlab-mr-review-workflow/SKILL.md create mode 100644 packages/platform-gitlab/skills/review/pm-risk-routing/SKILL.md create mode 100644 packages/platform-gitlab/skills/review/review-finding-schema/SKILL.md create mode 100644 packages/platform-gitlab/skills/review/security-review-policy/SKILL.md create mode 100644 packages/platform-gitlab/skills/review/spec-gate-review/SKILL.md create mode 100644 packages/platform-gitlab/skills/review/subagent-prompts/auto-fixer/SKILL.md create mode 100644 packages/platform-gitlab/skills/review/subagent-prompts/developer/SKILL.md create mode 100644 packages/platform-gitlab/skills/review/subagent-prompts/frontend-designer/SKILL.md create mode 100644 packages/platform-gitlab/skills/review/subagent-prompts/risk-qa/SKILL.md create mode 100644 packages/platform-gitlab/skills/review/subagent-prompts/security-agent/SKILL.md create mode 100644 packages/platform-gitlab/skills/review/subagent-prompts/spec-writer/SKILL.md create mode 100644 packages/platform-gitlab/skills/review/subagent-prompts/tech-architect/SKILL.md create mode 100644 packages/platform-gitlab/skills/review/verification-matrix/SKILL.md create mode 100644 packages/platform-gitlab/src/review/api-client.ts create mode 100644 packages/platform-gitlab/src/review/comment-renderer.ts create mode 100644 packages/platform-gitlab/src/review/context-builder.ts create mode 100644 packages/platform-gitlab/src/review/diff-builder.ts create mode 100644 packages/platform-gitlab/src/review/event-parser.ts create mode 100644 packages/platform-gitlab/src/review/finding-aggregator.ts create mode 100644 packages/platform-gitlab/src/review/idempotency.ts create mode 100644 packages/platform-gitlab/src/review/index.ts create mode 100644 packages/platform-gitlab/src/review/inline-position.ts create mode 100644 packages/platform-gitlab/src/review/output-schema.ts create mode 100644 packages/platform-gitlab/src/review/settings.ts create mode 100644 packages/platform-gitlab/src/review/types.ts create mode 100644 packages/platform-gitlab/src/review/webhook.ts create mode 100644 packages/platform-gitlab/src/review/workflow.ts create mode 100644 packages/platform-gitlab/test/gitlab-review.test.ts diff --git a/packages/platform-gitlab/GITLAB_CODE_REVIEW_PLUGIN_DESIGN.md b/packages/platform-gitlab/GITLAB_CODE_REVIEW_PLUGIN_DESIGN.md new file mode 100644 index 00000000..99a69173 --- /dev/null +++ b/packages/platform-gitlab/GITLAB_CODE_REVIEW_PLUGIN_DESIGN.md @@ -0,0 +1,1192 @@ +# GitLab Multi-Agent Code Review Plugin 架构设计 + +## 1. 定位 + +本功能把 GitLab MR / Commit 评论区的 `@Nine1bot` 和 GitLab webhook 作为 Nine1Bot 的平台入口,用 PM Coordinator 驱动一套多 agents 审查工作流,最终把阶段结论、阻塞项、修复建议和收口结果回写到 GitLab 评论中。 + +它不是把 GitLab 逻辑写进 Runtime core,也不是单独做一个 GitLab bot 服务。正确形态是: + +- `packages/platform-gitlab` 提供 GitLab 平台插件能力。 +- `packages/nine1bot` 负责产品级 controller、配置、路由挂载、session/profile 编译。 +- `opencode/` 作为 Nine1Bot Runtime 执行通用 agent loop / multi-agent orchestration。 +- `packages/platform-gitlab/agents/review` 先提供 PM、规格、架构、开发、QA、安全、修复、前端等 GitLab review agent prompt 资产。 +- `packages/platform-gitlab/skills/review` 先提供 GitLab review workflow、风险分级、spec gate、finding schema、评论渲染等 GitLab review skill 资产。 +- GitLab 只负责触发、上下文采集和结果回写,不拥有审查阶段语义。 + +核心链路: + +```text +GitLab MR/Commit comment or webhook + -> platform-gitlab 解析触发 + -> Nine1Bot Controller 创建 ReviewRun 并编译 AgentRunSpec + -> Runtime 启动 PM 主代理并由主代理派生子代理 + -> platform-gitlab 渲染并回写 GitLab 评论 +``` + +## 2. 顶层目录归属 + +```text +nine1bot/ + packages/ + platform-protocol/ + src/ + index.ts + + platform-gitlab/ + agents/ + review/ + pm-coordinator.agent.md + skills/ + review/ + gitlab-mr-review-workflow/ + SKILL.md + gitlab-commit-review-workflow/ + SKILL.md + spec-gate-review/ + SKILL.md + pm-risk-routing/ + SKILL.md + review-finding-schema/ + SKILL.md + verification-matrix/ + SKILL.md + security-review-policy/ + SKILL.md + gitlab-comment-rendering/ + SKILL.md + subagent-prompts/ + spec-writer/SKILL.md + tech-architect/SKILL.md + developer/SKILL.md + frontend-designer/SKILL.md + risk-qa/SKILL.md + security-agent/SKILL.md + auto-fixer/SKILL.md + src/ + review/ + api-client.ts + comment-renderer.ts + context-builder.ts + diff-builder.ts + event-parser.ts + finding-aggregator.ts + idempotency.ts + inline-position.ts + output-schema.ts + settings.ts + trigger.ts + types.ts + webhook.ts + workflow.ts + runtime.ts + index.ts + scripts/ + review-dry-run.ts + fixtures/ + review/ + sample-mr-webhook.json + sample-mr-changes.json + sample-discussions.json + + nine1bot/ + src/ + platform/ + config/ + launcher/ + engine/ + review/ + controller.ts + run-spec-compiler.ts + run-store.ts + event-router.ts + + opencode/ + packages/ + opencode/ + src/ + agent/ + session/ + runtime/ + tool/ + permission/ + mcp/ +``` + +## 3. 分层边界 + +### 3.1 `packages/platform-gitlab` + +这里放所有 GitLab 专属能力: + +- GitLab webhook 事件解析。 +- GitLab MR / Commit comment 中 `@Nine1bot` 触发识别。 +- GitLab REST API client。 +- MR metadata、diff、discussion、commit 数据读取。 +- GitLab diff position / inline comment 映射。 +- GitLab 顶层评论和 inline discussion 回写。 +- GitLab review settings、secret 引用和 allowlist 校验。 +- GitLab review agents / skills runtime source 声明。 +- 将 GitLab 数据转换成通用 `PlatformContextBlock`、资源声明、workflow trigger。 + +不能放: + +- Runtime agent loop 实现。 +- PM / QA / Security 阶段推进的硬编码执行逻辑。 +- OpenCode 内部工具调用逻辑。 +- 通用 workflow 状态机的产品持久化。 + +### 3.2 `packages/nine1bot` + +这里放产品级 controller 和通用 review workflow 编译: + +- 注册 `@nine1bot/platform-gitlab` 插件。 +- 挂载 `/platforms/gitlab/webhook` 或等价 route。 +- 管理 GitLab 平台配置和 secrets。 +- 接收 GitLab 插件输出的 `GitLabReviewTrigger` / `GitLabReviewRequest`。 +- 创建或恢复 `ReviewRun`。 +- 编译包含 GitLab review orchestration policy 的 `AgentRunSpec`。 +- 编译 session `profileSnapshot` 和每轮 `TurnRuntimeSnapshot`。 +- 接收 Runtime events,更新 review run 状态。 +- 调用 GitLab 插件进行最终评论回写。 + +建议新增: + +```text +packages/nine1bot/src/review/ + controller.ts + run-spec-compiler.ts + run-store.ts + event-router.ts +``` + +这层可以理解为“平台无关的 Code Review Controller”。它不执行子代理,也不做 multi-agent 调度,只负责把 GitLab/其他平台入口编译成 Runtime 可执行的 `AgentRunSpec`,并把 Runtime events 转换成平台可展示的状态。GitLab 是第一个入口,后续 GitHub、Gerrit、Bitbucket 也可以复用。 + +### 3.3 `opencode/` / Nine1Bot Runtime + +Runtime 只负责通用执行能力: + +- 根据 `AgentRunSpec` 执行单 agent 或 multi-agent。 +- 支持 `orchestration.mode = 'single' | 'parallel-review' | 'supervisor-workers' | 'plan-then-act'`。 +- 启动 PM Coordinator 主代理。 +- 为主代理提供受控的 subagent task 能力,让主代理在 Runtime 内派生子代理。 +- 加载 agent 定义、context blocks、tools、MCP、skills、permissions。 +- 发出 runtime events。 +- 处理 tool call、permission ask、resource failure。 + +Runtime 不应该知道: + +- GitLab webhook。 +- GitLab MR / note / discussion / diff_refs。 +- GitLab API URL。 +- GitLab inline comment position。 + +### 3.4 `packages/platform-gitlab/agents/review` + +这里先只放固定主代理: + +- `pm-coordinator.agent.md`:主流程 supervisor。 + +其余 7 个角色不作为固定 agent catalog 直接注册,而是改造成 skill 内的自定义子代理 prompt 模板。PM 主代理在 Runtime 内读取这些 skill instructions 后,通过 `SubagentTaskSpec.prompt` 创建自定义子代理。 + +迁移建议: + +```text +from: docs/agents/agents/pm-coordinator.agent.md +to: packages/platform-gitlab/agents/review/pm-coordinator.agent.md + +from: docs/agents/agents/{spec-writer,tech-architect,developer,frontend-designer,risk-qa,security-agent,auto-fixer}.agent.md +to: packages/platform-gitlab/skills/review/subagent-prompts//SKILL.md +``` + +首版直接使用当前架构提供的 `PlatformAdapterContribution.runtime.sources.agents` 注册平台 agent source,不需要新增全局 agent registry。Platform Adapter Manager 会在 GitLab 平台启用时注册该 source,在平台禁用时注销。 + +GitLab contribution 中建议声明: + +```ts +runtime: { + createAdapter: createGitLabPlatformAdapter, + sources: { + agents: [ + { + id: 'gitlab-review-agents', + directory: new URL('../agents', import.meta.url).pathname, + namespace: 'platform.gitlab', + visibility: 'recommendable', + lifecycle: 'platform-enabled', + }, + ], + }, +} +``` + +PM 主代理应命名为 `platform.gitlab.pm-coordinator`,并作为 `recommendedAgent` 或显式 session choice 使用;它不能进入 `defaultAgent()`。GitLab 平台禁用后,旧 session 如果冻结了该平台 agent,应按当前 runtime 语义 fail closed,并发出 `runtime.agent.unavailable`。 + +迁移时还要改造 prompt 内容: + +- 去掉另一个项目特有的路径、工具名和流程假设。 +- 将强制 spec coding 规则调整为“MR review workflow policy”,避免小 MR 因缺少三件套被无条件阻塞。 +- 将输出统一到 `review-finding-schema` skill 定义的 JSON Schema。 +- 明确哪些自定义子代理可以执行代码修改,哪些只能审查或产出建议。 +- 工具能力不写死在 prompt 中,而由 PM 生成 `SubagentTaskSpec.allowedTools`,Runtime 再强校验。 + +### 3.5 `packages/platform-gitlab/skills/review` + +这里放可复用的 workflow 方法、检查清单和输出协议。原则是: + +- Agent 负责“谁来做”。 +- Skill 负责“按什么方法做”。 +- Platform plugin 负责“从哪里来、回哪里去”。 +- Runtime orchestration 负责“如何执行和并行”。 + +建议新增这些 review skills: + +| Skill | 目录 | 职责 | +| --- | --- | --- | +| GitLab MR Review Workflow | `packages/platform-gitlab/skills/review/gitlab-mr-review-workflow/SKILL.md` | 定义 MR 从 trigger 到 closed 的阶段流 | +| GitLab Commit Review Workflow | `packages/platform-gitlab/skills/review/gitlab-commit-review-workflow/SKILL.md` | 定义 commit 评论触发下的窄范围审查 | +| Spec Gate Review | `packages/platform-gitlab/skills/review/spec-gate-review/SKILL.md` | 判断 requirements/design/tasks 是否足够支撑审查或实现 | +| PM Risk Routing | `packages/platform-gitlab/skills/review/pm-risk-routing/SKILL.md` | 判断 QA/Security/Frontend/Developer 的派发路径 | +| Review Finding Schema | `packages/platform-gitlab/skills/review/review-finding-schema/SKILL.md` | 统一 finding 严重级别、字段和 JSON 输出 | +| Verification Matrix | `packages/platform-gitlab/skills/review/verification-matrix/SKILL.md` | 指导 QA 建覆盖矩阵、最小验证集和未覆盖项 | +| Security Review Policy | `packages/platform-gitlab/skills/review/security-review-policy/SKILL.md` | 指导安全审查范围分级、跳过规则和阻塞口径 | +| GitLab Comment Rendering | `packages/platform-gitlab/skills/review/gitlab-comment-rendering/SKILL.md` | 指导如何把 stage result 渲染为 GitLab 评论 | +| Subagent Prompt Templates | `packages/platform-gitlab/skills/review/subagent-prompts/*/SKILL.md` | 提供 PM 创建自定义子代理时使用的角色 prompt 模板 | + +技能内容应尽量是平台可复用的流程知识。GitLab API endpoint、token 读取、webhook payload 解析仍然留在 `packages/platform-gitlab`。 + +首版直接使用当前架构提供的 `PlatformAdapterContribution.runtime.sources.skills` 注册平台 skill source。GitLab review skills 应使用 `visibility: 'declared-only'`,避免普通 Web session 默认继承。 + +GitLab contribution 中建议声明: + +```ts +runtime: { + createAdapter: createGitLabPlatformAdapter, + sources: { + skills: [ + { + id: 'gitlab-review-skills', + directory: new URL('../skills', import.meta.url).pathname, + namespace: 'platform.gitlab', + visibility: 'declared-only', + lifecycle: 'platform-enabled', + }, + ], + }, +} +``` + +`packages/platform-gitlab` 声明本次 review session 需要哪些 skill id,例如: + +```ts +skills: { + skills: [ + 'platform.gitlab.gitlab-mr-review-workflow', + 'platform.gitlab.spec-gate-review', + 'platform.gitlab.pm-risk-routing', + 'platform.gitlab.review-finding-schema', + 'platform.gitlab.verification-matrix', + 'platform.gitlab.security-review-policy', + 'platform.gitlab.gitlab-comment-rendering', + ], + lifecycle: 'session', + mergeMode: 'additive-only', +} +``` + +Controller 创建 review session 时将这些 GitLab 插件 skills 写入 `profileSnapshot.resources.skills`。Runtime 在本轮 `TurnRuntimeSnapshot` 中用 `includeDeclaredOnly` 解析平台 skill availability,并把可用的 skill instructions 交给 Context Pipeline。平台禁用后,旧 profile 中声明的平台 skill 会变成 unavailable,并走 `runtime.resource.failed` / audit。 + +## 4. 与 Runtime Sources 架构对齐 + +最近的 Runtime Source 架构已经支持平台包声明自己的 agent / skill source。GitLab review 必须使用这条路径: + +```ts +export const gitlabPlatformContribution = { + descriptor: gitlabPlatformDescriptor, + runtime: { + createAdapter: createGitLabPlatformAdapter, + sources: { + agents: [ + { + id: 'gitlab-review-agents', + directory: new URL('../agents', import.meta.url).pathname, + namespace: 'platform.gitlab', + visibility: 'recommendable', + lifecycle: 'platform-enabled', + }, + ], + skills: [ + { + id: 'gitlab-review-skills', + directory: new URL('../skills', import.meta.url).pathname, + namespace: 'platform.gitlab', + visibility: 'declared-only', + lifecycle: 'platform-enabled', + }, + ], + }, + }, +} satisfies PlatformAdapterContribution +``` + +含义: + +- Platform Adapter Manager 在 GitLab 启用时注册 sources,在禁用时注销 sources。 +- `PlatformManagerDetail.runtimeSources` 会暴露 source 注册状态,Web 多平台设置页可以展示 agents / skills source 摘要。 +- `recommendable` 平台 agent 不进入 `Agent.defaultAgent()`,但可被平台推荐或 session 显式选择。 +- `declared-only` 平台 skills 不进入普通 Web 会话,只有 GitLab review session 的 `profileSnapshot.resources.skills` 显式声明后才可解析。 +- 平台禁用是 hard gate。旧 session 后续 turn 如果引用 GitLab agent,会 fail closed 并发 `runtime.agent.unavailable`;如果引用 GitLab skill,会走 unavailable / resource failure / audit。 +- Runtime core 仍不 import `@nine1bot/platform-gitlab`。 + +## 5. GitLab 插件内部模块 + +建议在 `packages/platform-gitlab/src/review` 下实现: + +```text +review/ + types.ts +``` + +定义 GitLab review 相关类型: + +- `GitLabWebhookInput` +- `GitLabWebhookEvent` +- `GitLabReviewTrigger` +- `GitLabReviewRequest` +- `GitLabReviewSettings` +- `GitLabCommentPlan` +- `GitLabPublishResult` + +```text +review/event-parser.ts +``` + +职责: + +- 校验 webhook event kind。 +- 解析 note event、merge request event、commit comment event。 +- 输出稳定的 `GitLabWebhookEvent`。 +- 不访问网络,不写状态。 + +```text +review/trigger.ts +``` + +职责: + +- 判断评论是否包含 `@Nine1bot`。 +- 解析命令:`review`、`security`、`qa`、`fix`、`recheck`。 +- 提取用户附加指令。 +- 判断 MR note / commit note 是否可触发。 + +```text +review/idempotency.ts +``` + +职责: + +- 生成绑定代码状态的幂等 key。 +- 标记 review run 是否已处理。 +- 第一阶段可只提供接口和内存实现,真实持久化放到 `packages/nine1bot/src/review/run-store.ts`。 + +MR 审查幂等 key 必须包含 `head_sha`: + +```text +manual note: +gitlab:{host}:{projectId}:mr:{mrIid}:head_sha:{headSha}:note:{noteId} + +auto webhook: +gitlab:{host}:{projectId}:mr:{mrIid}:head_sha:{headSha}:auto:{eventName} +``` + +不能只使用 `mrIid`。同一个 MR 在 push 新 commit 后 `mrIid` 不变,但 `head_sha` 会变化,必须允许重新审查新代码。 + +```text +review/api-client.ts +``` + +职责: + +- GitLab REST API 最小 client。 +- 获取 MR metadata。 +- 获取 MR changes / diffs。 +- 获取 MR discussions。 +- 获取 commit 信息。 +- 创建 MR note / commit note。 +- 创建 inline discussion。 + +```text +review/diff-builder.ts +``` + +职责: + +- 将 GitLab diff 转为审查用 `changedFiles`。 +- 生成 changed file manifest。 +- 控制 diff token / bytes 预算。 +- 对大 MR 做摘要和裁剪。 +- 检测 GitLab API diff overflow / 截断。 +- 过滤 lock 文件、构建产物和二进制/多媒体/生成文件。 + +必须硬编码首版文件过滤黑名单: + +```ts +const excludedDiffFilePatterns = [ + /(^|\/)(package-lock|yarn\.lock|pnpm-lock|bun\.lockb?|composer\.lock|Gemfile\.lock)$/i, + /(^|\/)dist\//i, + /(^|\/)build\//i, + /(^|\/)coverage\//i, + /(^|\/)\.next\//i, + /(^|\/)\.nuxt\//i, + /\.min\.(js|css)$/i, + /\.(map|png|jpe?g|gif|webp|ico|pdf|zip|gz|tar|mp4|mov|mp3|woff2?|ttf|eot)$/i, +] +``` + +如果 GitLab changes/diff 返回 `overflow = true`,或关键 diff 为空但文件状态显示有变更,必须将 review run 标记为 `blocked`,并回写 MR 顶层评论: + +```text +MR 差异过大或被 GitLab 截断,Nine1Bot 拒绝自动审查。请人工 Review 或拆分 MR 后重新触发。 +``` + +禁止在 diff 不完整时让 agent 基于空 diff 继续审查。 + +```text +review/context-builder.ts +``` + +职责: + +- 将 `GitLabReviewRequest` 转为 `PlatformContextBlock[]`。 +- 生成 MR / Commit / diff / discussion / trigger note / output contract blocks。 +- 不拼接最终 prompt,只产出结构化 blocks。 + +```text +review/workflow.ts +``` + +职责: + +- 根据 GitLab trigger 和 changed files 生成 `GitLabReviewWorkflowPlan`。 +- 选择所需 GitLab review skills 和允许的 prompt assets。 +- 输出通用 workflow hints: + - `entry.mode = 'mr-review' | 'commit-review'` + - `orchestration.mode` + - `stages` + - `allowedTaskKinds` + +```text +review/output-schema.ts +``` + +职责: + +- 用 Zod 定义 `ReviewStageResult`、`ReviewFinding`、`ReviewRunReport`。 +- 生成传给 Runtime 的 JSON Schema。 +- 在 Controller 收口处反序列化和类型校验 Runtime 返回的 `Record`。 +- 不让 `opencode` / Runtime 依赖这些业务类型。 + +```text +review/finding-aggregator.ts +``` + +职责: + +- 对 QA / Security / Frontend / Architect 等子代理返回的 findings 先做代码侧聚合。 +- 按 `file + newLine + oldLine + category` 分组。 +- 合并重复 finding、保留来源 agents、取最高 severity。 +- 再把聚合结果交给 PM 主代理润色和冲突裁决。 +- 不把数组合并去重这种确定性逻辑交给 LLM。 + +```text +review/comment-renderer.ts +``` + +职责: + +- 将 Runtime 输出渲染为 GitLab Markdown 评论。 +- 顶层 summary comment。 +- 阶段失败 comment。 +- inline finding comment。 +- dry-run comment。 + +```text +review/inline-position.ts +``` + +职责: + +- 校验 finding 的 file/line 是否真的落在当前 diff hunk 的可评论行内。 +- 将合法 finding 映射为 GitLab discussion position。 +- 对非法行号或 GitLab API 400 做 fallback。 + +inline position 不能信任 LLM 行号。流程必须是: + +1. 拿到 finding `{ file, oldLine?, newLine? }`。 +2. 在原始 diff hunk 中解析可评论行集合。 +3. 只有 line 落在变更 hunk 中,才创建 inline discussion。 +4. 如果 line 不在 hunk 中,或 GitLab API 返回 400,将该 finding 降级为 summary note。 +5. inline 失败不能让整个 review run 失败。 + +```text +review/webhook.ts +``` + +职责: + +- 提供 `handleGitLabWebhook()` 这种无框架函数。 +- 输入 headers/body/settings/secrets/client factory。 +- 输出平台触发结果,不直接启动 HTTP server。 + +## 6. 通用 Review Controller + +建议在 `packages/nine1bot/src/review` 中放通用 controller。 + +```text +review/controller.ts +``` + +职责: + +- 接收平台插件产出的 `PlatformReviewTrigger`。 +- 创建 `ReviewRun`。 +- 调用 workflow compiler。 +- 调用 Runtime。 +- 收集 Runtime events。 +- 交给平台插件渲染和发布评论。 + +```text +review/run-spec-compiler.ts +``` + +职责: + +- 将 `GitLabReviewRequest` 这类平台请求编译成 Runtime 当前可消费的 `AgentRunSpec`。 +- 决定 `profileSnapshot`、`context.blocks`、`resources`、`permissions`、`orchestration`。 +- 写入 `orchestration.mode = 'supervisor-workers'`、PM 主代理、允许派生的子代理类型、可用 skills 和输出契约。 + +```text +review/run-store.ts +``` + +职责: + +- 持久化 review run 状态。 +- 记录幂等 key。 +- 记录阶段状态、trace id、GitLab note id、最终评论 id。 +- 第一阶段可以复用现有 storage,或先抽象接口。 + +```text +review/event-router.ts +``` + +职责: + +- 将 Runtime events 转换为 review run events。 +- 处理 resource failure、permission ask、supervisor started、subagent task started/completed、stage blocked。 +- 给 Web debug / GitLab comment renderer 提供统一事件源。 + +## 7. AgentRunSpec 与 Runtime Subagent Task + +为了不把 GitLab 阶段流写死到平台包,也不把 8 个 agents 固定绑定到某个 GitLab workflow,Controller 应把 GitLab review 编译成一个普通 `AgentRunSpec`,其中的 `orchestration` policy 指明本次运行由 Runtime 启动 PM 主代理,并允许主代理派生受控子代理。 + +核心思路: + +- `AgentRunSpec` 描述一次 review run 的来源、上下文、资源、skills、权限和 orchestration policy。 +- `PM Coordinator` 是唯一默认 supervisor。 +- 其他 reviewer / implementer / verifier 都由 PM 主代理在 Runtime 内按需生成 `SubagentTaskSpec`。 +- Runtime 提供通用 subagent task 执行能力,负责创建子代理、裁剪上下文、注入 skills、限制工具、收集结构化输出。 + +```ts +type SubagentTaskKind = + | 'discovery' + | 'spec' + | 'architecture-review' + | 'frontend-review' + | 'implementation' + | 'qa' + | 'security' + | 'fix' + | 'closed' + +type ReviewStageSpec = { + id: 'discovery' | 'spec' | 'implementation' | 'verification' | 'fix' | 'closed' + required: boolean + execution: 'supervisor' | 'subagent-task' | 'parallel-subagent-tasks' + dependsOn?: string[] + gate?: string +} + +type ReviewOrchestrationPolicy = { + mode: 'supervisor-workers' + supervisor: { + agent: 'platform.gitlab.pm-coordinator' + skills: string[] + } + stages: ReviewStageSpec[] + subagentTask: { + enabled: true + allowedTaskKinds: SubagentTaskKind[] + allowedPromptSkillRefs: string[] + allowedSkillRefs: string[] + maxParallelTasks: number + defaultTimeoutMs: number + } + output: { + schema: 'review-run-report-v1' + } +} +``` + +Controller 将该 policy 放入 `AgentRunSpec.orchestration`。Runtime 冻结 `TurnRuntimeSnapshot` 后,启动 PM 主代理。PM 在每个阶段根据 workflow skill、MR 上下文、风险分级和历史状态动态产出任务: + +```ts +type SubagentTaskSpec = { + id: string + stage: ReviewStageSpec['id'] + kind: SubagentTaskKind + prompt?: string + promptRef?: string + skills: string[] + contextRefs: string[] + allowedTools: string[] + outputSchema: Record + timeoutMs?: number + failureMode: 'abort-run' | 'ignore' | 'fallback' + fallbackPrompt?: string + parallelGroup?: string + dependsOn?: string[] + fileScope?: string[] +} +``` + +PM 主代理可以直接在 `prompt` 中写入自定义子代理提示词,也可以用 `promptRef` 引用 `packages/platform-gitlab/skills/review/subagent-prompts/*` 中的 prompt skill。`skills` 指向 Runtime 已解析的 skill catalog,例如 `platform.gitlab.verification-matrix`。`contextRefs` 指向 `TurnRuntimeSnapshot` 中已编译好的 context blocks 或 diff slices。`allowedTools` 只能从当前 session 已解析且权限允许的工具集合中取子集。 + +Runtime 不理解 `ReviewStageResult` 或 `ReviewFinding` 这类业务类型。`outputSchema` 是 Controller / GitLab 插件传入的 JSON Schema;Runtime 只负责约束模型输出符合 schema,并返回 `Record`。具体反序列化、Zod 校验、finding 聚合和 GitLab 渲染都在 `packages/platform-gitlab` / `packages/nine1bot/src/review` 收口。 + +`failureMode` 语义: + +- `abort-run`:任务失败或超时后立即中断整个 review run,并回写失败原因。PM Coordinator 自身必须使用该模式。 +- `ignore`:任务失败或超时后继续运行,最终报告中标记该节点未完成。QA/Security 可按风险使用该模式。 +- `fallback`:任务失败或超时后使用 `fallbackPrompt` 或更小上下文重试一次,失败后再按该阶段策略降级。 + +GitLab 插件只提供 workflow hints 和推荐 skill ids;Controller 只把这些信息编译成 `AgentRunSpec`;真正的阶段推进、主代理执行和子代理派生都发生在 Runtime 内。workflow 本身的阶段规则优先来自 skill,子代理选择由 PM 主代理动态决定,而不是写死在 GitLab 插件或 Controller 中。 + +## 8. PM 动态 Subagent Task 阶段流 + +用户设计的主链路建议落为 `GitLab Review Workflow v1`: + +```text +GitLab trigger + -> PM Coordinator 恢复状态 & 风险分级 + -> PM 读取 workflow skills + -> PM 在 Runtime 内按阶段动态创建 SubagentTaskSpec + -> Runtime 执行子代理任务并返回结构化结果 + -> PM 合并、裁决并推进下一阶段 + -> closed + -> GitLab comment publish +``` + +阶段与默认子代理模板: + +| 阶段 | PM 可能创建的任务 | prompt 来源 | 主要输出 | +| --- | --- | --- | --- | +| trigger | 无子代理,平台插件解析 | `platform-gitlab` | `GitLabReviewTrigger` | +| coordinator | supervisor 本体执行 | `platform.gitlab.pm-coordinator` | 风险分级、阶段路由、任务计划 | +| discovery | 文档取证任务 | `platform.gitlab.subagent-prompts.spec-writer` | 取证、基线、假设、影响模块 | +| spec | spec gate / spec 落文任务 | `platform.gitlab.subagent-prompts.spec-writer` | `requirements.md`、`design.md`、`tasks.md` 或 spec 缺口 | +| implementation | 架构审查任务、前端审查任务、开发实现任务 | `platform.gitlab.subagent-prompts.tech-architect` / `platform.gitlab.subagent-prompts.frontend-designer` / `platform.gitlab.subagent-prompts.developer` | 架构审查、实现建议、必要补丁或隔离子任务 | +| verification | QA 任务、安全任务,可并行 | `platform.gitlab.subagent-prompts.risk-qa` / `platform.gitlab.subagent-prompts.security-agent` | 测试结论、安全结论 | +| fix | 修复任务 | `platform.gitlab.subagent-prompts.auto-fixer` | 根因、最小补丁、定向复测 | +| closed | 收口记录任务 | `platform.gitlab.subagent-prompts.spec-writer` | 进度、变更日志、memory 收口 | +| publish | 无子代理,GitLab renderer 渲染 | `platform-gitlab` | MR / Commit 评论 | + +风险分级规则: + +- 涉及运行时代码、对外接口、权限、token、HTTP、shell、path、依赖、构建供应链:QA + Security 并行。 +- 仅测试代码、测试资源、非生产配置:只派发 QA。 +- 涉及 `web/**`、`.vue`、CSS、交互:implementation 阶段优先选择 Frontend Designer,verification 仍由 QA 负责。 + +PM 生成并行验证任务示例: + +```ts +const verificationTasks: SubagentTaskSpec[] = [ + { + id: 'verify-qa', + stage: 'verification', + kind: 'qa', + promptRef: 'platform.gitlab.subagent-prompts.risk-qa', + skills: ['platform.gitlab.verification-matrix', 'platform.gitlab.review-finding-schema'], + contextRefs: ['gitlab.mr.metadata', 'gitlab.diff.manifest', 'gitlab.diff.summary', 'gitlab.discussions'], + allowedTools: ['read', 'search', 'test'], + outputSchema: reviewStageResultJsonSchema, + failureMode: 'ignore', + parallelGroup: 'verification', + }, + { + id: 'verify-security', + stage: 'verification', + kind: 'security', + promptRef: 'platform.gitlab.subagent-prompts.security-agent', + skills: ['platform.gitlab.security-review-policy', 'platform.gitlab.review-finding-schema'], + contextRefs: ['gitlab.mr.metadata', 'gitlab.diff.manifest', 'gitlab.diff.summary'], + allowedTools: ['read', 'search'], + outputSchema: reviewStageResultJsonSchema, + failureMode: 'ignore', + parallelGroup: 'verification', + }, +] +``` + +这意味着 GitLab 插件和 Controller 都不需要知道子代理底层如何启动。PM 主代理只声明任务,Runtime 统一负责启动、隔离、执行、收集结果。 + +阶段与 skill 映射: + +| 阶段 | Skill | 主要用途 | +| --- | --- | --- | +| workflow bootstrap | `platform.gitlab.gitlab-mr-review-workflow` | 定义 MR review 的阶段顺序、门禁和默认降级策略 | +| workflow bootstrap | `platform.gitlab.gitlab-commit-review-workflow` | 定义 commit review 的窄范围流程 | +| coordinator | `platform.gitlab.pm-risk-routing` | PM Coordinator 按文件类型、权限面、运行时面做风险分级 | +| discovery/spec | `platform.gitlab.spec-gate-review` | 文档规格专家判断 spec 是否存在、是否完整、是否可指导审查 | +| verification | `platform.gitlab.verification-matrix` | QA 建立覆盖矩阵、最小验证集和未覆盖项 | +| verification | `platform.gitlab.security-review-policy` | Security 判定安全审查范围、跳过条件和阻塞口径 | +| all stages | `platform.gitlab.review-finding-schema` | 所有 agent 统一输出 finding / stage result | +| publish | `platform.gitlab.gitlab-comment-rendering` | 将结构化结果转成 GitLab summary / inline 评论 | + +## 9. GitLab 触发类型 + +### 8.1 MR 评论区 `@Nine1bot` + +GitLab note webhook: + +- `object_kind = 'note'` +- `object_attributes.noteable_type = 'MergeRequest'` +- `object_attributes.note` 包含 bot mention。 + +适合触发: + +- `@Nine1bot review` +- `@Nine1bot security` +- `@Nine1bot qa` +- `@Nine1bot fix` +- `@Nine1bot recheck` + +### 8.2 Commit 评论区 `@Nine1bot` + +GitLab note webhook: + +- `object_kind = 'note'` +- `object_attributes.noteable_type = 'Commit'` +- `object_attributes.note` 包含 bot mention。 + +适合触发较窄范围审查: + +- 只读取 commit diff。 +- 不执行完整 MR spec gate,除非能关联 MR。 +- 评论回写到 commit note。 + +### 8.3 Webhook 自动触发 + +GitLab merge request webhook: + +- MR opened。 +- MR updated。 +- MR marked ready。 +- label 包含配置的 auto-review label。 + +默认建议保守: + +- 第一阶段只启用手动 `@Nine1bot`。 +- 自动触发通过 settings 显式开启。 +- 自动触发必须有幂等和节流。 + +## 10. Context 和 History 关系 + +GitLab 插件构建 context blocks: + +- `platform:gitlab` +- `page:gitlab-mr` 或 `page:gitlab-commit` +- `business:gitlab-review-trigger` +- `business:gitlab-diff-manifest` +- `business:gitlab-diff-summary` +- `business:gitlab-existing-discussions` +- `runtime:review-output-contract` + +Controller 负责决定哪些内容进入 history: + +- 新 MR review run 创建时写入 synthetic context event。 +- 同一个 note 重放不重复写 history。 +- `recheck` 应复用上一轮 review run 摘要,并追加新的 context event。 + +Runtime 每个 agent stage 使用同一份 `TurnRuntimeSnapshot`,阶段内不重新采集 GitLab 状态。需要最新状态时由显式 GitLab context refresh 工具完成。 + +Subagent task 的上下文必须来自 Runtime 冻结的 `TurnRuntimeSnapshot`。PM 主代理可以给不同子代理分配不同 `contextRefs`,但不能让子代理绕过 snapshot 自行读取新的 GitLab 状态。这样可以保证同一阶段内 QA、安全、架构等子代理看到的是同一个 MR 世界。 + +## 11. 资源与权限 + +GitLab 插件声明资源,不直接创建 Runtime tool: + +```ts +type GitLabReviewResourceContribution = { + builtinTools: { + enabledGroups: ['gitlab-context', 'gitlab-review-comment'], + } + mcp: { + servers: [], + lifecycle: 'session', + mergeMode: 'additive-only', + } + skills: { + skills: [ + 'platform.gitlab.gitlab-mr-review-workflow', + 'platform.gitlab.spec-gate-review', + 'platform.gitlab.pm-risk-routing', + 'platform.gitlab.review-finding-schema', + 'platform.gitlab.verification-matrix', + 'platform.gitlab.security-review-policy', + 'platform.gitlab.gitlab-comment-rendering', + ], + lifecycle: 'session', + mergeMode: 'additive-only', + } +} +``` + +权限建议: + +- 读取 MR / diff / discussions:默认 allow,但受 host/project allowlist 限制。 +- 写 GitLab 评论:默认 ask 或由平台配置授权。 +- inline comment:比 summary comment 更高风险,可单独开关。 +- fix 阶段写文件 / push branch:第一阶段禁用,后续必须显式授权。 +- 子代理 `allowedTools` 必须是当前 session 已解析工具的子集,不能由 prompt 自行“发明”工具。 +- 子代理不能直接继承 supervisor 的全部工具;PM 主代理必须按任务类型显式裁剪,Runtime 再做强校验。 +- 子代理执行结果只返回给 PM 主代理 / Runtime,不直接展示给 GitLab 用户;最终展示由 PM 汇总后交给 GitLab renderer。 + +Secret: + +- Webhook secret 存在 Nine1Bot secret store。 +- GitLab token 存在 Nine1Bot secret store。 +- `packages/platform-gitlab` 只通过 `PlatformSecretAccess` 读取,不自行落盘。 + +## 12. 配置位置 + +GitLab code review 必须由用户在 Web 配置页显式启用,默认关闭。原因是该功能会接收外部 webhook、读取代码 diff、调用模型并可能写回 GitLab 评论,属于有外部攻击面和写操作的能力。 + +Web 配置入口建议放在现有“平台适配 / GitLab”详情页中: + +```text +web/src/components/ + PlatformSettingsPanel.vue # 若已有平台设置入口,则扩展它 + GitLabReviewSettingsPanel.vue # 可选:GitLab review 专用配置表单 +``` + +配置页应该分成四块: + +1. `Enable`:是否启用 GitLab code review,默认 `false`。 +2. `Connection`:GitLab host、project allowlist、bot mention。 +3. `Credentials`:Webhook secret、GitLab access token。 +4. `Review behavior`:手动 `@Nine1bot`、自动 webhook、inline comment、dry-run、diff 限额、多 agents 模式。 + +GitLab 平台 settings 建议扩展: + +```ts +type GitLabReviewSettings = { + enabled: boolean + botMention: string + gitlabBaseUrl: string + webhookSecretRef?: PlatformSecretRef + accessTokenRef?: PlatformSecretRef + allowedHosts: string[] + allowedProjects?: string[] + review: { + manualMentionEnabled: boolean + autoWebhookEnabled: boolean + autoReviewLabels: string[] + executionMode: 'supervisor-only' | 'dynamic-subagents' + maxDiffBytes: number + postSummaryComment: boolean + postInlineComments: boolean + dryRun: boolean + } +} +``` + +默认值: + +```ts +const defaultGitLabReviewSettings = { + enabled: false, + botMention: '@Nine1bot', + allowedHosts: [], + allowedProjects: [], + review: { + manualMentionEnabled: true, + autoWebhookEnabled: false, + autoReviewLabels: [], + executionMode: 'dynamic-subagents', + maxDiffBytes: 200_000, + postSummaryComment: true, + postInlineComments: false, + dryRun: true, + }, +} +``` + +实现归属: + +- 类型定义:`packages/platform-gitlab/src/review/settings.ts` +- 平台 descriptor 表单项:`packages/platform-gitlab/src/runtime.ts` +- schema 接入:`packages/nine1bot/src/config/schema.ts` +- Web 设置页展示:`web/src/components` 或现有平台设置页组件。 +- secret 存取:`packages/nine1bot/src/platform` / `preferences` 现有机制 +- 配置校验:`packages/platform-gitlab/src/review/settings.ts` + `packages/nine1bot/src/platform`。 + +配置页需要提示用户准备: + +- 一个 Nine1Bot 可公网访问或 GitLab 可访问的 webhook URL。 +- 一个 GitLab project webhook,至少开启 Comment events;自动审查再开启 Merge request events。 +- 一个 webhook secret,用于校验 GitLab 请求。 +- 一个 GitLab bot 账号或 project access token。 +- token 至少需要能读取 MR/diff/discussion 并创建 MR note;如果要写评论,通常需要 `api` scope。 +- 项目 allowlist,避免一个 token 意外作用到不该审查的项目。 + +## 13. 输出、聚合和回写 + +Runtime 输出是被 JSON Schema 约束后的通用 JSON: + +```ts +type RuntimeStructuredOutput = Record +``` + +GitLab review 业务层负责用 Zod 校验成具体结果: + +```ts +type ReviewRunReport = { + status: 'passed' | 'failed' | 'blocked' + stages: ReviewStageResult[] + findings: ReviewFinding[] + testsRun: string[] + securityChecks: string[] + uncoveredItems: string[] + recommendedNextStage: 'fix' | 'verification' | 'closed' + traceId: string +} +``` + +收口顺序: + +1. Runtime 返回 `Record`。 +2. `packages/platform-gitlab/src/review/output-schema.ts` 用 Zod 校验为 `ReviewStageResult` / `ReviewRunReport`。 +3. `finding-aggregator.ts` 先按 `file + oldLine + newLine + category` 对 findings 做代码侧聚合。 +4. PM 主代理只处理聚合后的冲突裁决和文字润色。 +5. `comment-renderer.ts` 转成 GitLab Markdown。 + +GitLab renderer 负责: + +- 将 `ReviewRunReport` 转成 GitLab Markdown。 +- 失败时突出 blocker / major findings。 +- 通过时说明验证范围和未覆盖风险。 +- inline comments 必须先经过 `inline-position.ts` 校验;校验失败或 GitLab API 400 时降级到 summary。 +- 评论中带 trace id 和幂等 key。 + +首版回写一个 MR 顶层 note,后续再做 inline。 + +## 14. 首版实现顺序 + +### Phase 0:Agent/Skill 迁移、Web 配置入口和 Dry-Run Harness + +实现目录: + +- `packages/platform-gitlab/agents/review/*.agent.md` +- `packages/platform-gitlab/skills/review/*/SKILL.md` +- `packages/platform-gitlab/src/review/settings.ts` +- `packages/platform-gitlab/src/runtime.ts` +- `packages/platform-gitlab/scripts/review-dry-run.ts` +- `packages/platform-gitlab/fixtures/review/*.json` +- `packages/nine1bot/src/config/schema.ts` +- `web/src/components/*` + +能力: + +- 将 `pm-coordinator.agent.md` 迁移到 `packages/platform-gitlab/agents/review`。 +- 将其余 7 个 agent 改造成 `packages/platform-gitlab/skills/review/subagent-prompts/*/SKILL.md`。 +- 新增 GitLab review workflow skills,将阶段流、风险分级、spec gate、finding schema、QA 矩阵、安全策略和 GitLab 评论渲染从固定 agent prompt 中抽出。 +- 在 `gitlabPlatformContribution.runtime.sources` 中声明 GitLab review agent / skill sources,分别使用 `recommendable` 和 `declared-only` visibility。 +- Controller 可以按 `profileSnapshot.resources.skills` 显式声明 GitLab review skills,Runtime 通过 declared-only source 解析并注入可用 skill instructions。 +- GitLab code review 默认关闭。 +- Web 配置页可以启用功能、配置 host/project allowlist、bot mention、webhook secret、access token、dry-run、inline comment、自动 webhook 等选项。 +- 配置页展示用户需要在 GitLab 侧创建的 webhook URL 和所需 token 权限。 +- 提供本地 dry-run harness:不依赖真实 GitLab webhook,直接读取 fixture 的 MR webhook、changes、discussions JSON,跑通 trigger、diff-builder、context-builder、run-spec-compiler 和 comment-renderer。 + +### Phase 1:平台触发闭环 + +实现目录: + +- `packages/platform-gitlab/src/review/types.ts` +- `event-parser.ts` +- `trigger.ts` +- `api-client.ts` +- `idempotency.ts` +- `diff-builder.ts` +- `context-builder.ts` +- `comment-renderer.ts` +- `webhook.ts` +- `packages/platform-gitlab/test/*` + +能力: + +- 解析 GitLab note webhook。 +- 识别 `@Nine1bot review`。 +- 拉 MR metadata / diff / discussions。 +- 幂等 key 绑定 MR `head_sha`。 +- 拦截 diff overflow 和黑名单文件。 +- 生成 context blocks。 +- 渲染 summary comment。 + +### Phase 2:Nine1Bot Controller 接入 + +实现目录: + +- `packages/nine1bot/src/review/controller.ts` +- `run-spec-compiler.ts` +- `run-store.ts` +- `event-router.ts` +- `packages/nine1bot/src/platform/*` +- `packages/nine1bot/src/config/*` + +能力: + +- 挂载 GitLab webhook route。 +- 接入 settings / secrets。 +- 创建 review run。 +- 编译包含 `ReviewOrchestrationPolicy` 的 `AgentRunSpec`。 +- 调用 Runtime。 + +### Phase 3:多 agents 工作流 + +实现目录: + +- `packages/platform-gitlab/src/review/workflow.ts` +- `packages/platform-gitlab/src/review/output-schema.ts` +- `packages/platform-gitlab/src/review/finding-aggregator.ts` +- `packages/nine1bot/src/review/run-spec-compiler.ts` +- `packages/nine1bot/src/review/event-router.ts` +- `opencode/packages/opencode/src/agent` +- `opencode/packages/opencode/src/runtime` +- `opencode/packages/opencode/src/session` + +能力: + +- PM Coordinator 作为唯一默认 supervisor。 +- PM 主代理在 Runtime 内根据 workflow skills 动态生成 `SubagentTaskSpec`。 +- Runtime 提供通用 subagent task 执行能力。 +- Runtime 只接收 JSON Schema 并返回 `Record`,不依赖 review 业务类型。 +- QA + Security 作为并行 subagent tasks,而不是 GitLab 插件硬编码 worker。 +- 大型任务由 PM 主代理拆解 developer subagent tasks,并通过 `fileScope` 保证文件隔离。 +- 子代理结果先经过代码侧 findings 聚合,再交给 PM 主代理做裁决。 + +### Phase 4:inline comments 和 recheck + +实现目录: + +- `packages/platform-gitlab/src/review/inline-position.ts` +- `packages/platform-gitlab/src/review/comment-renderer.ts` +- `packages/nine1bot/src/review/run-store.ts` + +能力: + +- finding 映射 diff line。 +- 创建 inline discussion。 +- 支持 `@Nine1bot recheck` 增量复审。 + +## 15. 验收标准 + +第一阶段完成时: + +- 未包含 `@Nine1bot` 的 GitLab 评论不会触发。 +- MR note 中 `@Nine1bot review` 可以生成唯一 review run。 +- 同一 `head_sha + noteId` webhook 重放不会重复执行或重复评论。 +- MR push 新 commit 后 `head_sha` 变化,自动审查必须能产生新的 review run。 +- GitLab API token 缺失时返回可审计错误。 +- GitLab diff overflow / 截断时必须 blocked 并回写提示,不允许空 diff 审查。 +- lock 文件、构建产物、多媒体和生成文件不会进入 LLM diff 上下文。 +- 能构造 MR metadata、diff manifest、discussion summary context blocks。 +- 能生成 `entry.platform = 'gitlab'`、`entry.mode = 'mr-review'` 的 workflow input。 +- 能声明 review workflow 所需 skills,并在 debug/audit 中展示 skill availability。 +- 能把 Runtime 结果回写为 MR 顶层评论。 +- 能用 `packages/platform-gitlab/scripts/review-dry-run.ts` 基于 fixture 跑通主要链路。 +- GitLab 专属逻辑不进入 `opencode/`。 +- `packages/platform-gitlab` 有单元测试覆盖 event parser、trigger、context builder、renderer。 + +多 agents 阶段完成时: + +- PM Coordinator 能根据 MR 风险选择阶段路径并生成 `SubagentTaskSpec`。 +- discovery / spec / implementation / verification / fix / closed 都有结构化 stage result。 +- verification 能按风险并行 QA + Security subagent tasks,或仅 QA。 +- 阶段规则来自 review skills,agents 只承接角色职责。 +- Runtime audit 能显示每个 subagent task 的 prompt/promptRef、skills、contextRefs、allowedTools、输出 schema 和状态。 +- subagent timeout 按 `failureMode` 执行:PM abort-run,QA/Security 可 ignore 或 fallback。 +- Runtime 不依赖 `ReviewFinding` / `ReviewStageResult` TypeScript 类型,只按传入 JSON Schema 约束输出。 +- inline comment 只对 diff hunk 内合法行创建;非法行号和 GitLab 400 必须降级到 summary。 +- closed 阶段能回写最终 MR 评论。 +- Runtime event / audit 能解释每个阶段使用的 agent、context、resources 和权限。 + +## 16. GitLab 开源版能力调查 + +基于 GitLab 官方文档,GitLab Free / Self-Managed 已经提供实现首版所需的大部分能力: + +| 能力 | GitLab 支持情况 | 本项目用途 | +| --- | --- | --- | +| Project Webhooks | GitLab Webhooks 文档标注 Free / Premium / Ultimate 均支持,GitLab.com、Self-Managed、Dedicated 均可用 | 接收 MR、comment、push 等事件 | +| Comment events | Webhook events 文档说明 commit、merge request、issue、snippet 新增或编辑评论会触发 comment event,header 为 `X-Gitlab-Event: Note Hook` | `@Nine1bot` 评论触发 | +| Merge request events | Webhook events 文档说明 MR 创建、编辑、合并、关闭、源分支新增 commit 会触发 MR event | 自动审查或 recheck | +| Project Webhooks API | Free / Premium / Ultimate 支持,但需要管理员或项目 Maintainer / Owner | 后续可在 Web UI 中自动创建或检测 webhook | +| Merge Requests API | 可读取 MR 元信息、状态、diff refs、changes 等 | 构造 MR review context | +| Notes API | 支持列出、读取、创建、更新 MR notes;MR note 不绑定具体 diff 行 | 首版 summary comment 回写 | +| Discussions API | 支持 MR discussion、thread note、resolve/reopen;可创建 MR diff thread | 后续 inline review comments | +| Commits API | 支持 commit comment,提供 `path`、`line`、`line_type` 等参数 | Commit 评论触发和回写 | +| Project access token | scopes 包括 `read_api`、`read_repository`、`api` 等;`api` 是 scoped project API 的完整读写访问 | Bot 账号读 MR、写 note | +| GitLab CLI `glab` | `glab mr note` 可管理 MR comments/discussions | 本地调试可选,不作为服务端依赖 | +| System hooks | Free / Self-Managed 支持,但属于实例级 hook | 企业自托管可选,不作为首版依赖 | + +首版推荐用户手动配置 project webhook: + +- URL:Nine1Bot 暴露的 `/platforms/gitlab/webhook`。 +- Secret token:填入 Web 配置页生成或保存的 webhook secret。 +- Trigger:开启 `Comments` / `Note events`。 +- 可选 Trigger:开启 `Merge request events` 用于自动审查。 +- SSL verification:生产环境应开启。 + +首版推荐 token 方案: + +- 优先:专用 GitLab bot 用户的 project access token。 +- scope:如果只读可用 `read_api`;如果需要写 MR note / discussion,使用 `api`。 +- role:项目内最小可用角色,通常至少需要能读取代码和创建评论。 +- 不建议首版要求 `write_repository`,因为首版不 push 修复分支。 +- token 存在 Nine1Bot secret store,不写入 `packages/platform-gitlab`。 + +官方参考: + +- [GitLab Webhooks](https://docs.gitlab.com/user/project/integrations/webhooks/) +- [GitLab Webhook events](https://docs.gitlab.com/user/project/integrations/webhook_events/) +- [GitLab Project webhooks API](https://docs.gitlab.com/api/project_webhooks/) +- [GitLab Merge requests API](https://docs.gitlab.com/api/merge_requests/) +- [GitLab Notes API](https://docs.gitlab.com/api/notes/) +- [GitLab Discussions API](https://docs.gitlab.com/api/discussions/) +- [GitLab Commits API](https://docs.gitlab.com/api/commits/) +- [GitLab Project access tokens](https://docs.gitlab.com/user/project/settings/project_access_tokens/) +- [GitLab CLI `glab mr note`](https://docs.gitlab.com/cli/mr/note/) + +## 17. 关键风险与约束 + +- 幂等 key 必须绑定 MR `head_sha`,否则 push 新 commit 后会被误判为已处理。 +- GitLab diff overflow / 截断必须 blocked,不能让 agent 基于空 diff 输出幻觉审查。 +- GitLab inline position 复杂,首版应先做 summary comment;开启 inline 后必须有 hunk validator 和 API 400 fallback。 +- 8 个 agents 来自另一个项目,其中一些规则带有强 spec coding 假设;首版应将这些方法论移入 GitLab package skills,主代理通过创建自定义子代理来消费,不应阻塞所有小 MR。 +- 自动 webhook review 容易产生噪音,首版默认手动 `@Nine1bot`。 +- 大 MR diff 需要裁剪,否则会拖慢 Runtime 并污染上下文。 +- QA/Security 等并行子代理结果必须先做代码侧 groupBy 聚合,再交给 PM 裁决。 +- Runtime 不能依赖 GitLab review 业务类型;业务层通过 JSON Schema/Zod 约束和校验输出。 +- 所有 subagent task 必须定义 `failureMode`,否则 timeout 后流程不可预期。 +- Phase 0/1 必须提供 dry-run harness,否则 prompt 和 workflow 调试会严重依赖真实 GitLab MR,迭代成本过高。 +- GitLab token 权限必须最小化,评论写权限要可配置、可审计。 +- 多 agents 并行实现必须保持文件隔离,否则自动实现阶段容易产生冲突。 diff --git a/packages/platform-gitlab/README.md b/packages/platform-gitlab/README.md index d581058d..2f9f7c7b 100644 --- a/packages/platform-gitlab/README.md +++ b/packages/platform-gitlab/README.md @@ -14,6 +14,14 @@ Nine1Bot Runtime core. - OpenCode / Nine1Bot Runtime core must only depend on the generic platform adapter registry, not this package directly. +## Code Review Plugin + +See [GITLAB_CODE_REVIEW_PLUGIN_DESIGN.md](./GITLAB_CODE_REVIEW_PLUGIN_DESIGN.md) +for the initial design of the `@Nine1bot` merge request review flow. The design +keeps GitLab webhook parsing, MR context loading, review orchestration, and +comment publishing inside this platform package while exposing only generic +context/resource contributions to the controller and runtime. + ## Adding Another Platform Use this package as the copyable example: diff --git a/packages/platform-gitlab/agents/review/pm-coordinator.agent.md b/packages/platform-gitlab/agents/review/pm-coordinator.agent.md new file mode 100644 index 00000000..dc3e5769 --- /dev/null +++ b/packages/platform-gitlab/agents/review/pm-coordinator.agent.md @@ -0,0 +1,218 @@ +--- +name: platform.gitlab.pm-coordinator +description: GitLab review PM coordinator. Primary runtime agent that restores review state, routes risk, creates custom subagents, and produces final GitLab review decisions. +mode: primary +permission: + edit: deny + bash: deny +--- + +# 角色定义 +你是拥有丰富软件工程和项目管理经验的资深项目经理。 +你负责统一接收用户需求,恢复真实项目状态,读取代码、进度文档、长期计划文件、技术方案、需求文档、评审结论,再把任务通过 handoff 精准分配给最合适的 agent。 + +# 强制工作方式 +1. 先恢复状态,再推进执行。 + - 开工前必须先读取 `/docs/execution/progress/`、`/docs/execution/plans/`、当前任务的 Spec Bundle、`memories/pm/`、对应的业务技术方案文档和相关代码现状。 + - 如果进度文档与代码不一致,以代码与测试结果为准,再回写进度文档。 + - 若是新任务且 `specs//` 尚不存在,必须先进入 `spec` 并要求 `文档规格专家` 按 `specs/_task-template/` 创建三件套后再继续。 + - 若是新的业务技术方案,必须为该技术方案单独创建一套 Spec Bundle,不得借用其他技术方案的 spec 目录。 +2. 每次接到新需求,必须从以下维度逐项推演后再下结论:业务目标、合规要求、性能、可观测性、回滚、测试覆盖、脏数据隔离、幂等、资源释放、可运营性。 +3. 审阅 5 遍,再发起 handoff。 + - 下发任务前,必须再次检查任务边界、输入输出、涉及文件、风险点和验收标准是否完整。 +4. 尽量降低用户介入。 + - 如果没有核心业务冲突,不要频繁反问用户;优先自主读取文档、检索代码、推断上下文并安排执行。 + - 如果需求已经足够明确,禁止只停留在方案、建议、待办列表或参数确认;必须继续推进到实际执行。 +5. 验证阶段必须先做风险分级,再决定是否派发安全审查专家。 + - 若 `改动文件` 全部位于 `**/src/test/**`,或全部属于测试资源、测试夹具、E2E 脚本,则判定为"仅测试变更",安全审查可跳过。 + - 若改动仅涉及 `src/main/resources` 下的非生产 profile 配置,例如 `application-default.properties`、`application-dev*.properties`、`application-sandbox.properties`、`application-smoke.properties`、`application-test*.properties`,且未同时改动运行时代码或线上配置文件,则判定为"仅非生产配置变更",安全审查可跳过。 + - 只有改动涉及运行时代码、构建供应链、对外接口、安全边界,或生产/预生产环境配置文件时,才必须派发 `安全审查专家`。 +6. 你是唯一的主流程编排者。 + - 只有你负责决定阶段切换。 + - 除你之外,其他 agent 都没有 handoff 权,不能决定下一阶段。 + - 你必须根据上游 agent 的结构化结果决定是否进入下一阶段。 + +# 绝对禁止事项 +1. 你不得直接修改任何业务代码、测试代码、构建配置或运行配置。 +2. 你不得直接执行编译、测试、运行、发布、脚本修复等实现性命令。 +3. 你不得因为"自己已经理解需求"就跳过 `技术架构师`、`前端设计专家`、`质量保证专家`、`安全审查专家`、`代码修复专家` 的专业分工。 +4. 若任务处于 `spec` 阶段,必须交给 `文档规格专家`;若处于 `implementation` 阶段,必须根据任务性质交给 `技术架构师`(后端/全栈)或 `前端设计专家`(纯前端),若需并行可同时派发 `开发工程师`;若处于 `verification` 阶段,必须先按变更文件做风险分级:存在运行时/上线攻击面时并行派发 `质量保证专家` 和 `安全审查专家`,否则仅派发 `质量保证专家`;若处于 `fix` 阶段,必须交给 `代码修复专家`。 +5. 若需要更新 Spec Bundle、计划、进度、change log 或 repo-local memory,必须交给 `文档规格专家`,不得自行编辑。 +6. 若需求会改动某份业务技术方案覆盖的代码,PM 必须先确保该方案绑定的 Spec Bundle 已更新,且对应 `docs/dev/*.md` 技术方案已同步,再允许进入 implementation。 +7. 你不得把"模板三件套已经生成"直接等同于"Spec Bundle 已审阅通过";只有任务化内容完整且审阅通过后,才能进入 `implementation`。 + +# 默认闭环执行模式 +1. 默认目标不是"给方案",而是"在当前对话内尽可能完成完整交付"。 +2. 当需求已明确且不存在外部硬阻塞时,你必须在同一轮内尽量走完:恢复状态 -> 最小必要取证 -> spec 落文 -> PM 审阅三件套 -> 实现 -> 测试 -> 安全审查 -> 修复 -> 收口。 +2.1 对新任务,spec 落文的最低动作是先在 `specs//` 生成 `requirements.md`、`design.md`、`tasks.md`,再回填任务化内容并完成 PM 审阅。 +2.2 对已有业务技术方案的增量修改,spec 落文必须连同对应技术方案文档一起同步更新,不能只改三件套不改方案。 +3. 只有以下情况才允许中途停下并向用户要输入: + - 缺少外部凭证、环境变量、云资源、第三方接口权限。 + - 业务口径存在高风险歧义,继续实现会明显误伤业务。 + - 操作具有不可逆或高破坏性,需要用户显式授权。 +4. 如果只是存在"可选优化项"或"可配置参数",应先按仓库现有风格和保守默认值落地,再在结尾给出后续建议。 +5. 你不能把"继续执行"这件事再次交还给用户决定;你应当先执行,再在最终结论中汇报结果和下一步建议。 +6. 若某个 specialist 的执行命令超时、挂起或未返回结构化 JSON,你必须把当前阶段判定为未完成,并明确阻塞点;禁止用过程播报冒充阶段完成。 +7. 你必须把每个任务看作一个"变更单元",优先保持单一意图、单一收口路径;若目标已经实质变化,要判断是更新现有任务还是拆分新任务。 + +# 阶段枚举 +你必须把每个任务绑定到以下枚举之一,并在流转时显式更新: +1. `discovery`:需求理解、文档取证、范围确认。 +2. `spec`:requirements/design/tasks 三件套落文与 PM 审阅。 +3. `implementation`:架构设计、任务拆解与实现(可并行)。 +4. `verification`:验证阶段——PM 必须先做风险分级;存在运行时/上线攻击面时并行派发 `质量保证专家`(功能测试与代码审查)和 `安全审查专家`(安全审查),仅测试或仅非生产配置变更时只派发 `质量保证专家`,再按已派发结果统一裁决。 +5. `fix`:缺陷修复与回归准备。 +6. `closed`:结果收口、进度与变更记录完成。 + +# Handoff 路由规则 +1. 文档重、证据重、需要读 PDF/Word、需要抽取页码证据 -> `文档规格专家`(discovery 阶段) +2. 需要把确认结论写成 requirements/design/tasks 三件套 -> `文档规格专家`(spec 阶段) +3. 后端/全栈架构设计、核心业务逻辑实现、数据层设计、API 设计、消息队列/存储/数据库链路 -> `技术架构师` +4. 前端页面/组件设计与实现、UI/UX 交互、样式与响应式、前端工程化 -> `前端设计专家` +5. 架构师或前端专家拆解后的独立子任务并行实现 -> `开发工程师`(仅在 PM 确认子任务独立后才派发) +6. 覆盖测试、回归测试、E2E 脚本、并发与脏数据测试、改动代码审查、缺陷根因分析 -> `质量保证专家` +7. 合规自查、漏洞扫描分析、三方依赖安全分析、联网漏洞情报收集 -> `安全审查专家` +8. 测试失败、线上缺陷、OOM、死锁、资源泄露、回归修复、安全修复 -> `代码修复专家` +9. 需要回写进度、change log、Spec Bundle 收口状态或 repo-local memory -> `文档规格专家`(closed 阶段) + +# 阶段与角色强绑定 +1. `discovery` 只能派发给:`文档规格专家` +2. `spec` 只能派发给:`文档规格专家` +3. `implementation` 根据任务性质主线派发给:`技术架构师`(后端/全栈/数据层)或 `前端设计专家`(纯前端);当主线 agent 产出可并行子任务且 PM 确认文件无冲突后,可同时派发 `开发工程师` 并行工作;若任务同时涉及前后端,可同时派发 `技术架构师` + `前端设计专家` 并行工作 +4. `verification` 必须先做风险分级:若变更包含运行时/上线攻击面,则优先同时派发 `质量保证专家` 和 `安全审查专家` 作为两个同轮 subagent(两者无结果依赖);若仅为测试变更或仅为非生产 profile 配置变更,则只派发 `质量保证专家` 并记录安全审查跳过原因 +5. `fix` 只能派发给:`代码修复专家` +6. `closed` 记录动作只能派发给:`文档规格专家` + +# 并行实现模式 +当任务进入 `implementation` 阶段且规模较大时,PM 可启用并行实现模式: + +## 阶段一:架构设计与拆解(顺序执行) +1. PM 在 handoff prompt 约束中加入 `parallel_decomposition=true`。 +2. `技术架构师` 完成 Spec Gate → 架构审查 → 规则审查 → 数据契约审查,产出: + - 架构决策。 + - 子任务清单(每个子任务含 sub_task_id、description、files_involved、dependencies、parallel_ready)。 + - 核心/共享子任务的实现已完成。 +3. PM 接收架构师结果,进入文件冲突检查。 + +## 阶段二:文件冲突检查(PM 执行) +1. 提取所有 `parallel_ready=true` 的子任务的 `files_involved`。 +2. 检查任意两个并行子任务之间是否存在文件交集。 +3. 若存在交集 → 将冲突子任务标记为串行,由架构师依次完成。 +4. 若无交集 → 确认可安全并行。 + +## 阶段三:并行实现(条件触发) +1. PM 同时派发 `技术架构师`(承接剩余核心子任务)和 `开发工程师`(承接独立子任务)。 +2. 两者互不依赖,PM 收齐两份结果后合并。 +3. 每次并行最多 2 个实现 agent(1 架构师 + 1 开发工程师)。 +4. 若还有更多子任务,PM 进行多轮并行,直到全部完成。 + +## 阶段四:合并与验证前检查(PM 执行) +1. PM 收齐所有子任务结果后,检查: + - 是否有文件被同时修改(合并冲突)。 + - 是否有语义不兼容的改动。 + - 编译状态是否正常。 +2. 若发现冲突 → 派发 `技术架构师` 解决冲突。 +3. 若合并成功 → 进入 `verification` 阶段。 + +## 小型任务快捷路径 +- 若 PM 评估任务规模较小(单一功能、少量文件),直接派发 `技术架构师` 独立完成全部实现,不启用并行模式。 +- 这是大多数任务的默认路径。 + +# 并行验证模式 +当 `implementation` 完成且 `status=ready` 时,PM 进入并行验证模式: +1. 必须先根据 `改动文件` 判断是否存在运行时/上线攻击面。 +2. 若存在运行时/上线攻击面,优先使用 subagent 能力同时派发 `质量保证专家` 和 `安全审查专家`,让两者在同一轮并行工作;若运行时确实不支持真正并行,才允许在同一轮内连续启动两者,且不得等待前者结果再决定是否启动后者。 +3. 若仅为测试变更,或仅为非生产 profile 配置变更,跳过 `安全审查专家`,只派发 `质量保证专家`,并在进度与裁决中记录"security skipped by scope"。 +4. `质量保证专家` 专注功能测试、覆盖评估、回归验证和改动代码审查;`安全审查专家` 专注合规自查、漏洞分析与攻击面审查。两者互不等待、互不依赖对方结果。 +5. PM 收齐所需 JSON 结果后统一裁决: + - QA 通过且安全审查因范围被跳过 → 进入 `closed`。 + - QA 通过且安全审查通过 → 进入 `closed`。 + - 任一已派发验证方 `failed`,或 `质量保证专家` 报出阻塞级代码审查问题 → 合并所有问题清单与根因假设,一次性派发 `代码修复专家`,避免 qa→fix→qa→security→fix→security 的串行往返。 + - `代码修复专家` 修复完成后,仅重跑失败方(不重跑已通过方),除非修复改动显著影响了已通过方的验证范围。 +6. 并行验证模式下 `current_stage` 统一记为 `verification`;`质量保证专家` 内部仍报 `current_stage=qa`,`安全审查专家` 仍报 `current_stage=security`,PM 负责将两者统一到 `verification` 阶段进行裁决。 +7. 并行验证的效率收益:将原本 qa→security 的串行链路缩短为单阶段,同时在任一方出现问题时一次性收集全部缺陷、一次性修复,避免多轮 fix 往返。 + +# Handoff Prompt 组装规范 +1. handoff prompt 必须同时包含四部分:输入边界、默认动作、禁止项、结果格式。 +2. handoff prompt 必须要求 specialist 只做最小必要读取,避免重复搬运上下文。 +3. handoff prompt 必须要求 specialist 默认直接产出可执行结果,而不是方案、建议或待办。 +4. handoff prompt 必须要求 specialist 只在真实硬阻塞时返回 blocked 或 need_clarification。 +5. handoff prompt 必须要求 specialist 只返回统一 JSON,不先写大段说明。 +6. handoff prompt 中应尽量明确 artifacts 的子字段,减少 PM 二次解析成本。 +7. 对 `质量保证专家`、`安全审查专家` 和 `代码修复专家` 的 handoff prompt 必须明确要求输出已执行结果,避免 QA/Security/Fix 阶段再次空转。 +8. handoff prompt 必须明确要求 specialist 使用固定 summary 句式,避免 PM 再解析自由文本。 +9. handoff prompt 必须与当前阶段的强绑定角色一致;若角色不匹配,则禁止派发。 +10. 对 `verification` 阶段,handoff prompt 必须先体现风险分级结论:需要安全审查时,再明确 `质量保证专家` 与 `安全审查专家` 是并行 subagent;若因范围跳过安全审查,也必须在输入包中记录跳过依据。其中 `质量保证专家` 还必须输出改动代码的 code review 结论与阻塞问题。 +11. 对 `implementation` 并行模式,handoff prompt 必须明确 `开发工程师` 的子任务范围和文件列表,防止越界修改。 + +# 派发前最小输入包模板 +1. PM 在每次派发前必须先组装最小输入包,固定字段不得缺失: + - `task_id` + - `目标` + - `范围` + - `约束` + - `验收标准` + - `改动文件` +2. 对 `开发工程师` 的派发还必须附加: + - `sub_task_id` + - `files_involved`(子任务严格文件列表) + - `dependencies`(依赖的前置子任务 ID) + - `architecture_decisions`(架构师的设计决策摘要) +3. 若某字段当前为空,也必须显式给默认值,而不是省略。 +4. PM 不得在最小输入包缺失的情况下直接派发 specialist。 + +# 产出要求 +1. 在 `/docs/execution/plans/` 记录拆解计划。 +2. 在 `/docs/execution/progress/` 记录当前真实进度。 +3. 在 `/docs/execution/progress/agent-team-changelog.md` 记录关键变更。 +4. 在 `specs//requirements.md`、`design.md`、`tasks.md` 维护任务级 Spec Bundle。 +4.1 新任务的 Spec Bundle 必须优先参考 `specs/_task-template/` 生成,不得临时手写单文件计划替代。 +4.2 每套 Spec Bundle 必须标明对应的技术方案文档路径。 +5. 在 `memories/pm/` 维护 repo-local 长期上下文、约束和未决事项。 + +# Spec 审阅最小检查表字段 +当你审阅新任务的 Spec Bundle 时,至少必须逐项检查以下字段,任一缺失、空白、仍为模板占位文本或与当前代码现场冲突,均不得进入 `implementation`: +1. `任务目标` +1.1 `对应技术方案` +2. `业务边界` +3. `非范围说明` +4. `默认值与既有约束` +5. `受影响模块` +6. `当前检查点` +7. `最小验证计划` +8. `阻塞项` +9. `验收标准` +10. `关键文件或目录` +11. `前置依赖与外部条件` +12. `关键设计决策与取舍` +13. `规格追踪关系` +14. `当前行为基线与本次变更摘要` +15. `关键场景` +16. `技术方案同步状态` + +# SDD 审阅补充原则 +1. 你必须把 discovery 证据、spec 字段、后续实现/测试/安全/修复动作串成一条可追踪链,而不是只检查文档是否存在。 +2. 若某个 spec 字段仅基于假设而非证据,必须明确标注为假设,并决定是允许保守默认值推进,还是留在 `spec` 阶段继续澄清。 +3. 若 spec 中已经存在关键设计决策,后续 specialist 不得绕开该决策自行改写业务口径;若要变更,必须先回到 `spec`。 +4. 若需求已明确但外部条件尚未满足,必须把该条件记录到 `前置依赖与外部条件` 与 `阻塞项`,而不是让 specialist 自行猜测。 +5. 若本次工作属于对既有能力的增量修改,必须先写清"当前行为基线"和"本次 delta",再允许进入实现。 +5.1 若本次工作绑定某份业务技术方案,必须先写清"对应技术方案路径"和"本轮是否已同步技术方案",再允许进入实现。 +6. 你必须判断当前变更是继续更新现有任务,还是应该拆成新任务:若意图已改变、范围爆炸、与原任务重叠过低或原任务已可独立收口,应优先拆新任务。 +7. 进入 `closed` 前,必须确认已完成 verify 式核对:至少看 completeness、correctness、coherence 是否存在明显缺口。 +8. 进入 `closed` 前,必须确认实现、测试、安全结论已回写到 Spec Bundle、进度或 memory 中,避免代码先行而 spec 不同步。 + +# 协调口径 +1. 所有 agent 的结论先回到你这里。 +2. 你要基于结果继续 handoff,而不是把中间判断留给用户。 +3. discovery 完成后,默认先进入 `spec`,由 `文档规格专家` 落三件套。 +4. Spec Bundle 落完后,你必须先按"Spec 审阅最小检查表字段"逐项审阅三件套,且不得保留模板占位文本,并要确认已经形成 evidence -> spec -> implementation/test/security/fix 的可追踪关系。 +5. 审阅通过则自动进入 implementation,不等待人工再次指令;若未通过,继续留在 `spec` 并要求修正文档。 +6. 如果研发已完成固定顺序的内部审查与自检,才允许流转到 `verification`(并行验证)。 +7. 进入 `verification` 后,必须先按 `改动文件` 做风险分级;涉及运行时/上线攻击面时并行派发 `质量保证专家` 和 `安全审查专家`,否则只派发 `质量保证专家` 并记录安全审查跳过原因。收齐所需结果后再统一裁决。 +8. 若修复后重跑仍失败,由你决定是否继续修复或升级为阻塞。 +9. 你必须检查"当前任务所处阶段",禁止把同一任务再次发往相同阶段。 +10. 你必须读取上游 agent 的 `recommended_next_stage`,但不能机械照做,必须结合当前状态再判断。 +11. 只要任务仍可继续推进,你就必须继续推进;不要在"已有实施路径"时只输出方案摘要。 +12. 你的最终输出应当默认是"已完成了什么、验证结果如何、剩余风险和下一步是什么",而不是"建议用户下一步让我开始干活"。 +13. 你的职责是分派、审核、收口,不是实现、测试、修复或写业务代码。 + diff --git a/packages/platform-gitlab/docs/review-implementation/01-platform-package-construction.md b/packages/platform-gitlab/docs/review-implementation/01-platform-package-construction.md new file mode 100644 index 00000000..5da78519 --- /dev/null +++ b/packages/platform-gitlab/docs/review-implementation/01-platform-package-construction.md @@ -0,0 +1,212 @@ +# 01. Platform GitLab 包施工手册 + +本手册只覆盖 `packages/platform-gitlab` 内部实现。这里可以处理 GitLab 平台语义,但不能把 Runtime agent loop 或通用 workflow 状态机写进来。 + +## 1. 目标目录 + +```text +packages/platform-gitlab/ + agents/ + review/ + pm-coordinator.agent.md + skills/ + review/ + gitlab-mr-review-workflow/SKILL.md + gitlab-commit-review-workflow/SKILL.md + spec-gate-review/SKILL.md + pm-risk-routing/SKILL.md + review-finding-schema/SKILL.md + verification-matrix/SKILL.md + security-review-policy/SKILL.md + gitlab-comment-rendering/SKILL.md + subagent-prompts/ + spec-writer/SKILL.md + tech-architect/SKILL.md + developer/SKILL.md + frontend-designer/SKILL.md + risk-qa/SKILL.md + security-agent/SKILL.md + auto-fixer/SKILL.md + src/ + review/ + api-client.ts + comment-renderer.ts + context-builder.ts + diff-builder.ts + event-parser.ts + finding-aggregator.ts + idempotency.ts + inline-position.ts + output-schema.ts + settings.ts + trigger.ts + types.ts + webhook.ts + workflow.ts +``` + +## 2. Runtime sources + +在 `packages/platform-gitlab/src/runtime.ts` 的 `gitlabPlatformContribution.runtime` 中补充 sources: + +```ts +runtime: { + createAdapter: createGitLabPlatformAdapter, + sources: { + agents: [ + { + id: 'gitlab-review-agents', + directory: new URL('../agents', import.meta.url).pathname, + namespace: 'platform.gitlab', + visibility: 'recommendable', + lifecycle: 'platform-enabled', + }, + ], + skills: [ + { + id: 'gitlab-review-skills', + directory: new URL('../skills', import.meta.url).pathname, + namespace: 'platform.gitlab', + visibility: 'declared-only', + lifecycle: 'platform-enabled', + }, + ], + }, +} +``` + +要求: + +- PM 主代理名使用 `platform.gitlab.pm-coordinator`。 +- skills 使用 `platform.gitlab.*` 命名。 +- review skills 必须是 `declared-only`,避免普通 Web session 默认继承。 +- GitLab 平台禁用后,Platform Adapter Manager 会注销 sources,不需要平台包自行管理。 + +## 3. Webhook 与触发 + +实现文件: + +- `event-parser.ts` +- `trigger.ts` +- `webhook.ts` +- `types.ts` + +必须支持: + +- MR note:`object_kind = note` 且 `noteable_type = MergeRequest`。 +- Commit note:`object_kind = note` 且 `noteable_type = Commit`。 +- MR event 自动触发:后续通过 settings 显式开启。 + +触发规则: + +- 默认只启用手动 `@Nine1bot`。 +- 命令包括 `review`、`security`、`qa`、`fix`、`recheck`。 +- 未命中 mention 时必须返回 no-op,不写状态,不调用 Runtime。 +- webhook secret 校验失败必须返回明确错误,不进入 review run。 + +## 4. 幂等性 + +实现文件: + +- `idempotency.ts` + +MR review 的 key 必须绑定代码状态: + +```text +manual note: +gitlab:{host}:{projectId}:mr:{mrIid}:head_sha:{headSha}:note:{noteId} + +auto webhook: +gitlab:{host}:{projectId}:mr:{mrIid}:head_sha:{headSha}:auto:{eventName} +``` + +禁止只用 `mrIid`。MR push 新 commit 后 `headSha` 变化,必须允许生成新的 review run。 + +Commit review key: + +```text +gitlab:{host}:{projectId}:commit:{sha}:note:{noteId} +``` + +## 5. Diff builder + +实现文件: + +- `api-client.ts` +- `diff-builder.ts` + +必须处理: + +- MR metadata。 +- MR changes / diffs。 +- discussions。 +- GitLab API diff overflow / truncation。 +- 黑名单文件过滤。 + +首版硬编码过滤: + +```ts +const excludedDiffFilePatterns = [ + /(^|\/)(package-lock|yarn\.lock|pnpm-lock|bun\.lockb?|composer\.lock|Gemfile\.lock)$/i, + /(^|\/)dist\//i, + /(^|\/)build\//i, + /(^|\/)coverage\//i, + /(^|\/)\.next\//i, + /(^|\/)\.nuxt\//i, + /\.min\.(js|css)$/i, + /\.(map|png|jpe?g|gif|webp|ico|pdf|zip|gz|tar|mp4|mov|mp3|woff2?|ttf|eot)$/i, +] +``` + +Overflow 策略: + +- 如果 GitLab 返回 `overflow = true`,review run 必须 blocked。 +- 如果文件显示有变更但 diff 为空,且不是二进制/黑名单文件,review run 必须 blocked。 +- blocked 时回写 MR 顶层评论,提示 MR 差异过大或被 GitLab 截断。 + +## 6. Inline validator + +实现文件: + +- `inline-position.ts` + +要求: + +- 不信任 LLM 输出的行号。 +- 从原始 diff hunk 解析可评论行集合。 +- `newLine` / `oldLine` 必须存在于本次 diff hunk 中才允许创建 inline discussion。 +- GitLab API 返回 400 时,单条 finding 降级为 summary note。 +- inline 失败不能使整个 review run 失败。 + +## 7. Output schema 与聚合 + +实现文件: + +- `output-schema.ts` +- `finding-aggregator.ts` + +规则: + +- 用 Zod 定义 review 业务 schema,并导出 JSON Schema 给 Runtime。 +- Runtime 只返回 `Record`。 +- Controller / GitLab review 层负责 Zod 校验。 +- QA / Security / Frontend 等子代理 findings 先由 `finding-aggregator.ts` 按 `file + oldLine + newLine + category` 聚合。 +- 聚合后再交给 PM 主代理润色和裁决。 + +## 8. Renderer + +实现文件: + +- `comment-renderer.ts` + +首版只要求 summary note: + +- 总状态:passed / failed / blocked。 +- blocker / major findings。 +- 超时或跳过的子代理任务。 +- 未覆盖项。 +- trace id。 +- idempotency key。 + +inline comment 在 validator 完成后再开启,且必须支持 fallback。 + diff --git a/packages/platform-gitlab/docs/review-implementation/02-controller-runtime-integration.md b/packages/platform-gitlab/docs/review-implementation/02-controller-runtime-integration.md new file mode 100644 index 00000000..e66b4407 --- /dev/null +++ b/packages/platform-gitlab/docs/review-implementation/02-controller-runtime-integration.md @@ -0,0 +1,237 @@ +# 02. Controller 与 Runtime 接入施工手册 + +本手册描述 `packages/nine1bot` 与 `opencode` / Nine1Bot Runtime 的接入边界。 + +## 1. 边界原则 + +Controller 负责: + +- 挂载 GitLab webhook route。 +- 读取 Web 配置和 secrets。 +- 调用 `platform-gitlab` 解析触发和构造上下文。 +- 创建 / 恢复 `ReviewRun`。 +- 编译 `AgentRunSpec`。 +- 转译 Runtime events。 +- 调用 GitLab renderer 回写评论。 + +Runtime 负责: + +- 冻结 `TurnRuntimeSnapshot`。 +- 启动 `platform.gitlab.pm-coordinator` 主代理。 +- 为 PM 主代理提供受控 subagent task 能力。 +- 校验子代理工具、skills、context refs、JSON Schema。 +- 执行子代理并返回结构化 JSON。 + +Controller 不负责: + +- 派生子代理。 +- 并行调度 QA / Security。 +- 执行 agent loop。 +- 解释 Runtime 内部 tool call。 + +## 2. 建议 Controller 目录 + +```text +packages/nine1bot/src/review/ + controller.ts + run-spec-compiler.ts + run-store.ts + event-router.ts +``` + +### controller.ts + +职责: + +- 接收平台触发。 +- 做配置和 enabled hard gate。 +- 做 webhook secret 校验结果处理。 +- 写入 review run 初始状态。 +- 调用 `run-spec-compiler.ts`。 +- 调用 Runtime。 +- 监听 Runtime events。 +- 触发 GitLab 回写。 + +### run-spec-compiler.ts + +职责: + +- 将 `GitLabReviewRequest` 编译成 `AgentRunSpec`。 +- 设置: + - `entry.source = 'api'` + - `entry.platform = 'gitlab'` + - `entry.mode = 'mr-review' | 'commit-review'` + - `agent.name = 'platform.gitlab.pm-coordinator'` + - `orchestration.mode = 'supervisor-workers'` + - GitLab review skills。 + - GitLab context blocks。 + - JSON Schema 输出契约。 + +### run-store.ts + +职责: + +- 存储 review run。 +- 存储 idempotency key。 +- 存储 head sha、note id、trace id、GitLab comment ids。 +- 支持 dry-run 内存实现和正式持久化实现。 + +### event-router.ts + +职责: + +- 将 Runtime event 转成 review run event。 +- 处理: + - supervisor started / completed。 + - subagent task started / completed / timeout。 + - resource failure。 + - permission ask。 + - runtime agent unavailable。 + - blocked / failed / closed。 + +## 3. AgentRunSpec 编译要点 + +`AgentRunSpec` 中必须表达: + +- 平台来源。 +- MR / commit context blocks。 +- GitLab review skills。 +- PM 主代理。 +- subagent task policy。 +- output JSON Schema。 +- permissions。 +- audit trace。 + +示意: + +```ts +const spec = { + entry: { + source: 'api', + platform: 'gitlab', + mode: 'mr-review', + templateIds: ['gitlab-mr', 'gitlab-code-review'], + }, + agent: { + name: 'platform.gitlab.pm-coordinator', + source: 'session-choice', + }, + context: { + blocks, + }, + resources: { + builtinTools: { + enabledGroups: ['gitlab-context', 'gitlab-review-comment'], + }, + mcp: { + servers: [], + lifecycle: 'session', + mergeMode: 'additive-only', + }, + skills: { + skills: [ + 'platform.gitlab.gitlab-mr-review-workflow', + 'platform.gitlab.spec-gate-review', + 'platform.gitlab.pm-risk-routing', + 'platform.gitlab.review-finding-schema', + 'platform.gitlab.verification-matrix', + 'platform.gitlab.security-review-policy', + 'platform.gitlab.gitlab-comment-rendering', + ], + lifecycle: 'session', + mergeMode: 'additive-only', + }, + }, + orchestration: { + mode: 'supervisor-workers', + supervisor: { + agent: 'platform.gitlab.pm-coordinator', + }, + subagentTask: { + enabled: true, + allowedTaskKinds: ['discovery', 'spec', 'architecture-review', 'frontend-review', 'implementation', 'qa', 'security', 'fix', 'closed'], + allowedPromptSkillRefs: [ + 'platform.gitlab.subagent-prompts.spec-writer', + 'platform.gitlab.subagent-prompts.tech-architect', + 'platform.gitlab.subagent-prompts.developer', + 'platform.gitlab.subagent-prompts.frontend-designer', + 'platform.gitlab.subagent-prompts.risk-qa', + 'platform.gitlab.subagent-prompts.security-agent', + 'platform.gitlab.subagent-prompts.auto-fixer', + ], + maxParallelTasks: 2, + defaultTimeoutMs: 120_000, + }, + output: { + schema: reviewRunReportJsonSchema, + }, + }, +} +``` + +## 4. Runtime subagent task contract + +Runtime 需要支持 PM 主代理提交: + +```ts +type SubagentTaskSpec = { + id: string + stage: string + kind: string + prompt?: string + promptRef?: string + skills: string[] + contextRefs: string[] + allowedTools: string[] + outputSchema: Record + timeoutMs?: number + failureMode: 'abort-run' | 'ignore' | 'fallback' + fallbackPrompt?: string + parallelGroup?: string + dependsOn?: string[] + fileScope?: string[] +} +``` + +Runtime 校验: + +- `promptRef` 必须来自 allowed prompt skill refs。 +- `skills` 必须是当前 session 可用 skills 的子集。 +- `contextRefs` 必须来自 `TurnRuntimeSnapshot`。 +- `allowedTools` 必须是当前 resolved tools 的子集。 +- `outputSchema` 只作为 JSON Schema 约束,不绑定业务类型。 + +## 5. Failure policy + +必须实现: + +- `abort-run`:中断 review run,回写失败。 +- `ignore`:记录未完成,继续汇总已有结果。 +- `fallback`:用更小上下文或 fallback prompt 重试一次。 + +建议默认: + +- PM 主代理:`abort-run`。 +- QA / Security:`ignore` 或 `fallback`。 +- Fix:`abort-run` 或 `fallback`,按用户命令决定。 + +## 6. Runtime source live gate + +必须复用最新平台 source 语义: + +- GitLab 平台禁用后,不允许新 session 使用 GitLab review agent / skills。 +- 旧 session 引用 GitLab PM agent:fail closed,事件 `runtime.agent.unavailable`。 +- 旧 session 引用 GitLab skill:resource unavailable,进入 audit。 +- Platform Manager detail 中展示 runtime sources 注册状态。 + +## 7. Web 配置页 + +Web 使用现有 `PlatformManager.vue` / `/nine1bot/platforms` API: + +- GitLab code review 默认关闭。 +- secret 字段 redacted。 +- 展示 webhook URL。 +- 展示 runtime sources 状态。 +- 提供 connection test action。 +- 提供 dry-run action 可选。 + diff --git a/packages/platform-gitlab/docs/review-implementation/03-validation-and-dry-run.md b/packages/platform-gitlab/docs/review-implementation/03-validation-and-dry-run.md new file mode 100644 index 00000000..7716b70e --- /dev/null +++ b/packages/platform-gitlab/docs/review-implementation/03-validation-and-dry-run.md @@ -0,0 +1,182 @@ +# 03. 验证与 Dry-Run 施工手册 + +GitLab review 工作流涉及 webhook、API、diff、prompt、Runtime 和回写。首版必须先做本地 dry-run harness,避免每次调试都依赖真实 GitLab MR。 + +## 1. Dry-run 目标 + +脚本: + +```text +packages/platform-gitlab/scripts/review-dry-run.ts +``` + +Fixtures: + +```text +packages/platform-gitlab/fixtures/review/ + sample-mr-note-webhook.json + sample-mr-event-webhook.json + sample-mr-changes.json + sample-discussions.json + sample-overflow-changes.json + sample-inline-diff.patch +``` + +脚本最小链路: + +```text +load fixtures + -> event-parser + -> trigger + -> idempotency + -> diff-builder + -> context-builder + -> run-spec-compiler draft + -> comment-renderer dry-run summary +``` + +dry-run 不调用真实 GitLab,不调用真实 Runtime。 + +## 2. Dry-run CLI 行为 + +建议命令: + +```bash +bun run packages/platform-gitlab/scripts/review-dry-run.ts --fixture sample-mr-note-webhook +``` + +输出: + +- parsed trigger。 +- idempotency key。 +- changed files manifest。 +- filtered files。 +- overflow / blocked 状态。 +- generated context block ids。 +- AgentRunSpec 摘要。 +- rendered GitLab markdown。 + +错误情况要返回非 0: + +- fixture 缺失。 +- webhook 不可解析。 +- diff overflow blocked。 +- output schema 校验失败。 + +## 3. 单元测试清单 + +建议新增: + +```text +packages/platform-gitlab/test/gitlab-review-event-parser.test.ts +packages/platform-gitlab/test/gitlab-review-trigger.test.ts +packages/platform-gitlab/test/gitlab-review-idempotency.test.ts +packages/platform-gitlab/test/gitlab-review-diff-builder.test.ts +packages/platform-gitlab/test/gitlab-review-inline-position.test.ts +packages/platform-gitlab/test/gitlab-review-output-schema.test.ts +packages/platform-gitlab/test/gitlab-review-finding-aggregator.test.ts +packages/platform-gitlab/test/gitlab-review-renderer.test.ts +``` + +覆盖重点: + +- 非 mention 不触发。 +- MR note mention 触发。 +- Commit note mention 触发。 +- MR push 新 `head_sha` 生成新 key。 +- 同一 `head_sha + noteId` 重放不重复。 +- lock/build/media 文件被过滤。 +- diff overflow blocked。 +- inline line 必须在 diff hunk 中。 +- inline 400 fallback 到 summary。 +- QA/Security duplicate findings 聚合。 +- Runtime unknown JSON 经 Zod 校验。 + +## 4. Controller / Platform Manager 测试 + +建议新增或扩展: + +```text +packages/nine1bot/src/platform/manager.test.ts +packages/nine1bot/src/review/run-spec-compiler.test.ts +packages/nine1bot/src/review/event-router.test.ts +``` + +覆盖: + +- GitLab enabled 注册 agents / skills runtime sources。 +- GitLab disabled 注销 sources。 +- settings secret redaction。 +- `profileSnapshot.resources.skills` 显式声明 declared-only skills。 +- `AgentRunSpec.orchestration.mode = supervisor-workers`。 +- `runtime.agent.unavailable` 转成 review run blocked。 +- resource unavailable 转成 audit / warning。 + +## 5. Runtime 测试 + +建议新增或扩展: + +```text +opencode/packages/opencode/test/platform/runtime-source-registry.test.ts +opencode/packages/opencode/test/agent/platform-agent-source.test.ts +opencode/packages/opencode/test/skill/platform-skill-source.test.ts +opencode/packages/opencode/test/runtime/subagent-task.test.ts +``` + +覆盖: + +- recommendable platform agent 不进入 default agent。 +- declared-only platform skill 不进入默认 session。 +- 显式声明后可解析 declared-only skill。 +- 平台禁用后 agent fail closed。 +- subagent task 只能使用 allowed tools。 +- subagent task 只能引用 snapshot context refs。 +- Runtime 只按 JSON Schema 约束输出,不 import GitLab review schema。 +- `failureMode` 三种行为。 + +## 6. 回归命令 + +最小验证: + +```bash +bun run --cwd packages/platform-gitlab typecheck +bun test packages/platform-gitlab +bun run packages/platform-gitlab/scripts/review-dry-run.ts --fixture sample-mr-note-webhook +``` + +接入 Controller 后: + +```bash +bun run --cwd packages/nine1bot typecheck +bun test packages/nine1bot/src/platform packages/nine1bot/src/review +``` + +接入 Runtime 后: + +```bash +bun run --cwd opencode/packages/opencode typecheck +bun test opencode/packages/opencode/test/agent opencode/packages/opencode/test/skill opencode/packages/opencode/test/platform opencode/packages/opencode/test/runtime +``` + +Web 配置页后: + +```bash +bun test web/test +bun run build:web +``` + +最终检查: + +```bash +git diff --check +``` + +## 7. 必须保留的未覆盖说明 + +如果首版不接真实 GitLab,应在交付说明中明确: + +- 未验证真实 GitLab webhook 签名。 +- 未验证真实 GitLab inline discussion API。 +- 未验证真实 project access token 权限边界。 +- 已用 fixtures 覆盖 parser / diff / context / renderer。 + diff --git a/packages/platform-gitlab/docs/review-implementation/README.md b/packages/platform-gitlab/docs/review-implementation/README.md new file mode 100644 index 00000000..4a13a982 --- /dev/null +++ b/packages/platform-gitlab/docs/review-implementation/README.md @@ -0,0 +1,40 @@ +# GitLab Review 实施施工文档索引 + +本目录面向代码实施阶段,承接上层方案: + +- `packages/platform-gitlab/GITLAB_CODE_REVIEW_PLUGIN_DESIGN.md` +- `docs/agent-runtime-developer-guide/09-platform-adapter-development-guide.md` + +当前实现必须遵守最新平台架构: + +- GitLab 专属能力放在 `packages/platform-gitlab`。 +- GitLab review agents / skills 通过 `PlatformAdapterContribution.runtime.sources` 暴露给 Platform Adapter Manager。 +- Controller 只生成 `AgentRunSpec`、持久化 `ReviewRun`、转译 Runtime events。 +- Runtime 执行 PM 主代理,并允许主代理在 Runtime 内派生受控子代理。 +- Runtime 不依赖 `ReviewFinding`、`ReviewStageResult` 等 GitLab review 业务类型,只处理传入的 JSON Schema。 + +## 文档顺序 + +1. [01-platform-package-construction.md](./01-platform-package-construction.md) + - GitLab 包内目录、agents / skills、webhook、API client、diff、inline、renderer。 +2. [02-controller-runtime-integration.md](./02-controller-runtime-integration.md) + - Nine1Bot Controller、Platform Manager、Runtime source、AgentRunSpec、Runtime event 接入边界。 +3. [03-validation-and-dry-run.md](./03-validation-and-dry-run.md) + - dry-run harness、fixtures、单元测试、集成测试和回归命令。 + +## 第一阶段交付目标 + +Phase 0 / Phase 1 先交付可本地验证的最小闭环: + +```text +fixture webhook / GitLab note + -> trigger parser + -> idempotency key with head_sha + -> diff builder with overflow guard and file blacklist + -> context blocks + -> AgentRunSpec draft + -> dry-run renderer summary +``` + +真实 GitLab webhook 和 Runtime 多代理执行接入放在后续阶段,不阻塞本地施工验证。 + diff --git a/packages/platform-gitlab/fixtures/review/sample-mr-changes.json b/packages/platform-gitlab/fixtures/review/sample-mr-changes.json new file mode 100644 index 00000000..d6a6c280 --- /dev/null +++ b/packages/platform-gitlab/fixtures/review/sample-mr-changes.json @@ -0,0 +1,25 @@ +{ + "diff_refs": { + "base_sha": "1111111111111111111111111111111111111111", + "start_sha": "2222222222222222222222222222222222222222", + "head_sha": "3333333333333333333333333333333333333333" + }, + "changes": [ + { + "old_path": "src/auth.ts", + "new_path": "src/auth.ts", + "diff": "@@ -1,5 +1,6 @@\n export function canReview(user: User) {\n- return user != null\n+ if (!user) return false\n+ return user.role === 'maintainer'\n }\n", + "new_file": false, + "renamed_file": false, + "deleted_file": false + }, + { + "old_path": "package-lock.json", + "new_path": "package-lock.json", + "diff": "@@ -1,3 +1,3 @@\n { \"lockfileVersion\": 3 }\n", + "new_file": false, + "renamed_file": false, + "deleted_file": false + } + ] +} diff --git a/packages/platform-gitlab/fixtures/review/sample-mr-overflow.json b/packages/platform-gitlab/fixtures/review/sample-mr-overflow.json new file mode 100644 index 00000000..90d4ce8e --- /dev/null +++ b/packages/platform-gitlab/fixtures/review/sample-mr-overflow.json @@ -0,0 +1,14 @@ +{ + "overflow": true, + "diff_refs": { + "head_sha": "4444444444444444444444444444444444444444" + }, + "changes": [ + { + "old_path": "src/large.ts", + "new_path": "src/large.ts", + "diff": "", + "overflow": true + } + ] +} diff --git a/packages/platform-gitlab/package.json b/packages/platform-gitlab/package.json index a497026e..b93e2567 100644 --- a/packages/platform-gitlab/package.json +++ b/packages/platform-gitlab/package.json @@ -6,11 +6,13 @@ "exports": { ".": "./src/index.ts", "./browser": "./src/browser.ts", - "./runtime": "./src/runtime.ts" + "./runtime": "./src/runtime.ts", + "./review": "./src/review/index.ts" }, "scripts": { "test": "bun test", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "review:dry-run": "bun run scripts/review-dry-run.ts" }, "dependencies": { "@nine1bot/platform-protocol": "workspace:*" diff --git a/packages/platform-gitlab/scripts/review-dry-run.ts b/packages/platform-gitlab/scripts/review-dry-run.ts new file mode 100644 index 00000000..99034c2e --- /dev/null +++ b/packages/platform-gitlab/scripts/review-dry-run.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { + aggregateReviewFindings, + buildGitLabDiffManifest, + buildGitLabReviewIdempotencyKey, + renderBlockedDiffComment, + renderReviewSummaryComment, + validateGitLabInlinePosition, + type GitLabRawChangesResponse, + type ReviewFinding, +} from '../src/review' + +const fixturePath = resolve(process.cwd(), process.argv[2] ?? 'fixtures/review/sample-mr-changes.json') +const fixture = JSON.parse(readFileSync(fixturePath, 'utf8')) as GitLabRawChangesResponse +const manifest = buildGitLabDiffManifest(fixture) + +const idempotencyKey = buildGitLabReviewIdempotencyKey({ + host: 'gitlab.example.com', + projectId: 1, + objectType: 'mr', + objectIid: 10, + headSha: manifest.diffRefs?.headSha ?? 'dry-run-head', + mode: 'webhook', + eventName: 'merge_request', +}) + +if (manifest.blocked) { + console.log(JSON.stringify({ + idempotencyKey, + blocked: true, + comment: renderBlockedDiffComment(manifest.blockReason ?? 'Diff blocked.'), + }, null, 2)) + process.exit(0) +} + +const syntheticFindings: ReviewFinding[] = [ + { + title: 'Permission check changed', + body: 'Dry-run fixture detected a changed authorization decision line.', + severity: 'major', + category: 'auth', + file: 'src/auth.ts', + newLine: 3, + source: 'dry-run', + }, +] + +const inline = validateGitLabInlinePosition(syntheticFindings[0]!, manifest.files, manifest.diffRefs) +const findings = aggregateReviewFindings(syntheticFindings) +const comment = renderReviewSummaryComment({ + summary: 'Dry-run completed without calling GitLab or Runtime.', + findings, + manifest, + warnings: inline.ok ? [] : [inline.reason], +}) + +console.log(JSON.stringify({ + idempotencyKey, + blocked: false, + manifest, + inline, + comment, +}, null, 2)) diff --git a/packages/platform-gitlab/skills/review/gitlab-comment-rendering/SKILL.md b/packages/platform-gitlab/skills/review/gitlab-comment-rendering/SKILL.md new file mode 100644 index 00000000..0394c38f --- /dev/null +++ b/packages/platform-gitlab/skills/review/gitlab-comment-rendering/SKILL.md @@ -0,0 +1,17 @@ +--- +name: platform.gitlab.gitlab-comment-rendering +description: Use to render GitLab review results into concise merge request or commit comments. +--- + +# GitLab Comment Rendering + +Render for maintainers scanning a GitLab MR: + +- lead with blocked status or high-severity findings. +- include skipped files and timeout warnings. +- keep top-level summary short. +- use inline comments only after code-side hunk validation. +- if inline validation fails or GitLab returns 400, fall back to a top-level Markdown note with file and line context. + +Never hide blocked diff conditions behind a normal success summary. + diff --git a/packages/platform-gitlab/skills/review/gitlab-commit-review-workflow/SKILL.md b/packages/platform-gitlab/skills/review/gitlab-commit-review-workflow/SKILL.md new file mode 100644 index 00000000..83287a56 --- /dev/null +++ b/packages/platform-gitlab/skills/review/gitlab-commit-review-workflow/SKILL.md @@ -0,0 +1,11 @@ +--- +name: platform.gitlab.gitlab-commit-review-workflow +description: Use for narrow GitLab commit review runs triggered from commit comments. +--- + +# GitLab Commit Review Workflow + +Review the target commit in a narrow scope. Prefer direct changed-line feedback and avoid broad architectural conclusions unless the commit clearly touches shared production behavior. + +Commit review may skip spec-gate work when the request is only asking for localized feedback. Security and QA review still apply when the diff touches auth, permissions, storage, networking, dependency execution, runtime configuration, release scripts, or user data. + diff --git a/packages/platform-gitlab/skills/review/gitlab-mr-review-workflow/SKILL.md b/packages/platform-gitlab/skills/review/gitlab-mr-review-workflow/SKILL.md new file mode 100644 index 00000000..cf539cd9 --- /dev/null +++ b/packages/platform-gitlab/skills/review/gitlab-mr-review-workflow/SKILL.md @@ -0,0 +1,20 @@ +--- +name: platform.gitlab.gitlab-mr-review-workflow +description: Use for GitLab merge request review runs triggered by @Nine1bot comments or merge request webhooks. +--- + +# GitLab MR Review Workflow + +Treat the GitLab merge request as the source of truth for scope. Review only the included diff manifest and provided repository context. + +Stage order: + +1. discovery: identify changed files, risk areas, evidence, assumptions, and blocked conditions. +2. spec: decide whether available requirements, design notes, and task context are enough to review safely. +3. implementation: dispatch focused custom subagents when architecture, frontend, backend, QA, or security review is needed. +4. verification: merge structured findings and ask PM to decide severity, conflicts, and release risk. +5. fix: only propose or apply minimal patches when the run explicitly allows code changes. +6. closed: render a concise GitLab summary and include skipped files, fallback inline comments, and timed-out agents. + +Never invent findings outside the diff. If the diff is blocked, truncated, or empty after filters, stop and report the blocked state. + diff --git a/packages/platform-gitlab/skills/review/pm-risk-routing/SKILL.md b/packages/platform-gitlab/skills/review/pm-risk-routing/SKILL.md new file mode 100644 index 00000000..9ae7c90b --- /dev/null +++ b/packages/platform-gitlab/skills/review/pm-risk-routing/SKILL.md @@ -0,0 +1,17 @@ +--- +name: platform.gitlab.pm-risk-routing +description: Use to choose which custom subagents should be created for a GitLab review run. +--- + +# PM Risk Routing + +Route by blast radius: + +- Security subagent: auth, access control, secrets, dependency loading, command execution, network boundaries, user data, persistence, webhook verification. +- QA subagent: behavior changes, tests, migrations, release configuration, failure handling, cross-platform paths. +- Frontend subagent: UI, layout, accessibility, state, browser integration, user-facing text. +- Technical architecture subagent: shared contracts, runtime boundaries, plugin APIs, orchestration, persistence, config. +- Developer subagent: implementation patches only when the run allows code changes. + +Use `failureMode: abort-run` for PM/spec gates that are required. Use `ignore` or `fallback` for optional QA/Security/Frontend subagents and report timeouts in the final comment. + diff --git a/packages/platform-gitlab/skills/review/review-finding-schema/SKILL.md b/packages/platform-gitlab/skills/review/review-finding-schema/SKILL.md new file mode 100644 index 00000000..6d71999f --- /dev/null +++ b/packages/platform-gitlab/skills/review/review-finding-schema/SKILL.md @@ -0,0 +1,24 @@ +--- +name: platform.gitlab.review-finding-schema +description: Use to produce structured GitLab code review findings. +--- + +# Review Finding Schema + +Return findings as JSON-compatible objects: + +```json +{ + "title": "short finding title", + "body": "why this matters and what should change", + "severity": "info | minor | major | critical | blocker", + "category": "optional stable category", + "file": "optional repo path", + "oldLine": 12, + "newLine": 18, + "source": "agent role" +} +``` + +Only include `file` and line fields when they are grounded in the diff manifest. Prefer no line over a guessed line. + diff --git a/packages/platform-gitlab/skills/review/security-review-policy/SKILL.md b/packages/platform-gitlab/skills/review/security-review-policy/SKILL.md new file mode 100644 index 00000000..b66ce92b --- /dev/null +++ b/packages/platform-gitlab/skills/review/security-review-policy/SKILL.md @@ -0,0 +1,16 @@ +--- +name: platform.gitlab.security-review-policy +description: Use for security review of GitLab MR or commit diffs. +--- + +# Security Review Policy + +Prioritize concrete exploit paths: + +- untrusted input reaching command execution, filesystem, network, SQL, templates, eval-like APIs, or deserialization. +- secret exposure in logs, comments, config, prompts, artifacts, or errors. +- missing webhook validation, permission checks, allowlists, or project scoping. +- unsafe token scopes or write-back behavior. + +Avoid generic advice. Findings must name the affected path and the failing guard. + diff --git a/packages/platform-gitlab/skills/review/spec-gate-review/SKILL.md b/packages/platform-gitlab/skills/review/spec-gate-review/SKILL.md new file mode 100644 index 00000000..87245182 --- /dev/null +++ b/packages/platform-gitlab/skills/review/spec-gate-review/SKILL.md @@ -0,0 +1,15 @@ +--- +name: platform.gitlab.spec-gate-review +description: Use to decide whether a GitLab review has enough product and technical context to proceed. +--- + +# Spec Gate Review + +Classify the review context as: + +- ready: enough information exists to review changed behavior. +- partial: review can proceed with explicit assumptions. +- blocked: missing context would make review misleading. + +Do not block merely because a repository lacks formal spec documents. Block only when the MR purpose, expected behavior, or acceptance criteria cannot be inferred from the MR description, linked context, commit messages, and changed code. + diff --git a/packages/platform-gitlab/skills/review/subagent-prompts/auto-fixer/SKILL.md b/packages/platform-gitlab/skills/review/subagent-prompts/auto-fixer/SKILL.md new file mode 100644 index 00000000..01bc9bf5 --- /dev/null +++ b/packages/platform-gitlab/skills/review/subagent-prompts/auto-fixer/SKILL.md @@ -0,0 +1,64 @@ +--- +name: platform.gitlab.subagent-prompts.auto-fixer +description: Prompt template for the GitLab review auto fixer custom subagent. +--- + +# 角色定义 +你的设计目标参考长上下文日志、调用链与安全问题联合分析风格的专家系统,专注于生产问题的根因分析和最小补丁修复。 +你是测试失败、线上事故和安全审查失败后的最后一道防线。 + +# 核心能力与约束 +1. 根因分析必须覆盖以下候选维度:代码缺陷、配置缺陷、资源释放问题、并发时序问题、依赖变更问题、安全漏洞或安全配置缺陷。 +2. 修复前后必须逐项检查:是否误伤业务语义、是否引入回归、是否改变监控口径、是否引入新的安全风险。 +3. 修复策略。 + - 优先修根因,不做表面补丁。 + - 优先做最小且可验证的修改。 + - 允许修复代码、测试、构建配置、依赖版本、默认安全配置和防护逻辑。 + - 修复后必须给出已完成的补丁与最小验证结果,而不是只给根因候选。 +4. 记忆与复盘。 + - repo-local 记忆目录是 `memories/fixer/`。 + - 重大事故或高危安全问题要沉淀故障模式、复现条件和预防建议。 +5. handoff 约束。 + - 你没有 handoff 权,也不负责决定下一阶段。 + - 修复完成后只输出"建议复测"或"建议重新安全审查",由 `资深项目经理` 决定是否交给 `质量保证专家` 或 `安全审查专家`。 + - 在并行验证模式下,你可能同时收到 `质量保证专家` 和 `安全审查专家` 的合并问题清单,应一次性修复所有问题,而不是分批处理。 + +# 修复优先规则 +1. 只要根因已经足够清晰,就直接补丁、补测试、补保护逻辑或升级依赖,不要停在分析报告。 +2. 修复前必须先读取当前任务的 `requirements.md`、`design.md`、`tasks.md`,判断问题属于实现偏差还是 spec 缺口。 +2.1 若三件套仍停留在模板骨架、未完成任务化回填或 PM 审阅,必须优先判定为 spec 缺口,不得直接按实现缺陷处理到底。 +2.2 若 Spec Bundle 缺少前置依赖、设计决策或规格追踪关系,必须把该缺口纳入根因候选,避免只修表层实现问题。 +3. 对当前任务强相关的显性回归、资源泄露、空指针、并发时序问题、安全配置缺陷、依赖漏洞和输入校验缺失,应尽量同轮修到可复测状态。 +4. 若仍无法修复,必须明确指出阻塞点、已排除路径和下一步最短修复路径;若根因来自 spec 缺口,必须显式指出需要先修订 spec。 +4.1 修复结论应尽量回答“是 requirement drift、design drift、tasks drift,还是纯实现缺陷”,让 PM 能更快决定回 spec 还是继续复测。 +4.2 若修复导致原任务的设计意图已被显著调整,必须提醒 PM 判断“继续更新当前任务”还是“拆出新任务/新 change”。 +5. 修复后的验证同样必须遵守有限时长原则:先跑最小复现和最小回归,再视情况扩大验证;若验证命令超时或挂起,必须返回当前补丁状态和挂起证据,不得无限等待。 + +# 防挂起与超时硬规则 +1. 所有修复后的验证命令必须遵守以下超时限制,超时后必须立即终止命令并输出当前补丁状态与最后可见输出: + - 单个测试类/文件复现:最长 120 秒。 + - 最小回归验证(模块级):最长 300 秒。 + - 全量构建:最长 600 秒。 +2. 禁止在单次会话中读取超过 15 个文件后仍未开始产出任何修复补丁;必须基于已有证据尽快给出最小修复。 +3. 禁止在终端中运行交互式命令或需要用户手动输入确认的命令。 +4. 若某一步骤已成功完成但后续步骤挂起,必须保留已成功的结果并输出部分完成状态,不得因后续挂起而丢弃全部进度。 +5. 若外部服务连接在 15 秒内未建立,必须视为环境不可达并返回 blocked,不得无限等待。 + +# 统一 JSON 结果模板 +1. `task_id`: 任务唯一标识。 +2. `current_stage`: 固定填写 `fix`。 +3. `status`: `ready`、`blocked`、`need_clarification` 三选一。 + - 已修复或部分修复通过 `summary` 表达。 +4. `summary`: 必须使用固定句式:`修复结论:已修复/部分修复{问题范围};根因{root_causes 摘要};验证{tests_run 摘要};建议进入{recommended_next_stage}。` +5. `artifacts`: 产物数组,至少必须包含以下子字段: + - `root_causes` + - `patch_summary` + - `files_changed` + - `security_fixes` + - `tests_run` + - `remaining_gaps` + - `retest_focus` +5.1 `artifacts` 在条件允许时还应补充 `drift_type`,标识本次修复针对的是 requirement/design/tasks 哪一层偏差。 +6. `risks`: 未完全消除的风险数组。 +7. `recommended_next_stage`: 只能填写 `verification`、`fix`、`closed` 之一。 +8. `needs_pm_attention`: `true|false`。 diff --git a/packages/platform-gitlab/skills/review/subagent-prompts/developer/SKILL.md b/packages/platform-gitlab/skills/review/subagent-prompts/developer/SKILL.md new file mode 100644 index 00000000..72f5d7b2 --- /dev/null +++ b/packages/platform-gitlab/skills/review/subagent-prompts/developer/SKILL.md @@ -0,0 +1,88 @@ +--- +name: platform.gitlab.subagent-prompts.developer +description: Prompt template for the GitLab review developer custom subagent. +--- + +# 角色定义 +你是项目的开发工程师,在技术架构师或前端设计专家完成架构设计和子任务拆解后,承接独立子任务进行并行实现。 +你必须严格在 PM 分配的子任务范围内工作,不得扩展到其他模块或文件。 +你必须遵循架构师/前端专家已确定的架构决策、接口定义和数据契约。 + +# 核心约束 +1. 你只能修改 PM 分配的子任务中明确列出的文件范围,严禁修改范围外的文件。 +2. 你必须遵循架构师/前端专家在 design.md 和架构审查结论中确定的所有设计决策。 +3. 你必须遵守项目约定的技术栈和编码规范。 +4. 你必须在 spec coding 模式下工作:先读取 Spec Bundle,再在子任务范围内编码。 +5. 若遇到超出子任务范围的问题,必须返回 blocked,不得私自扩展作用域。 +6. 你必须为关键方法提供详细的中文代码注释。 +7. 能枚举化的值不要进行纯字符串硬编码,要么枚举类/对象,要么常量。 + +# 工作流程 +1. 读取当前任务的 Spec Bundle(requirements.md、design.md、tasks.md)。 +2. 读取 PM 分配的子任务描述、涉及文件列表和依赖关系。 +3. 确认依赖的前置子任务已完成(共享基础设施已就绪)。 +4. 读取 `memories/engineer/` 获取历史实现上下文。 +5. 在子任务范围内完成编码和单元测试。 +6. 运行最小验证集(与子任务直接相关的测试)。 +7. 输出结构化结果。 + +# 自检清单 +1. 是否完整读取并遵守了 Spec Bundle。 +2. 是否只修改了子任务范围内的文件(`scope_violations` 为空)。 +3. 是否复用了架构师/前端专家已完成的共享基础设施和既有实现。 +4. 是否遵循项目约定的技术栈和编码风格。 +5. 是否规避了资源泄露风险(如未关闭的流、未释放的连接、内存泄漏等)。 +6. 是否补齐了核心主流程、关键分支和异常路径测试。 +7. 是否为关键方法提供了详细中文注释。 + +# 执行优先规则 +1. 默认交付物是已提交到工作区的代码和测试,不是实现计划。 +2. 严格遵守子任务范围,不得修改范围外的文件。 +3. 如果能在当前上下文中确定默认值,就直接采用保守默认值落地,并在结果中说明。 +4. 遇到明显且子任务强相关的编译错误、测试错误,应在同一轮内修掉。 +5. 代码改动默认先跑与改动直接相关的最小验证集,再尝试模块级验证。 +6. 未拿到真实执行结果前,禁止在 `summary` 中写"验证通过"。 +7. 若当前子任务的 Spec Bundle 不完整或与代码现场冲突,必须返回 blocked。 + +# 记忆管理 +- repo-local 记忆目录是 `memories/engineer/`,与技术架构师共享。 +- 每次实现前优先读取 `memories/engineer/` 获取历史上下文。 + +# handoff 约束 +- 你没有 handoff 权,也不负责决定下一阶段。 +- 子任务完成后只上报结果,由 `资深项目经理` 合并判定。 +- 如果子任务阻塞、需求边界变化,只输出阻塞原因和建议,不自行分发。 + +# 防挂起与超时硬规则 +1. 所有终端命令必须遵守以下超时限制,超时后必须立即终止命令并输出已完成步骤与最后可见输出: + - 单个测试类/文件运行:最长 120 秒。 + - 模块级测试:最长 300 秒。 + - 全量构建:最长 600 秒。 + - 任何其他终端命令:最长 180 秒。 +2. 执行构建/测试命令时,必须遵守渐进式验证策略,按以下顺序递进: + - 第一步:编译/构建确认。 + - 第二步:运行与改动最直接相关的单个测试类/文件。 + - 第三步:运行改动模块的全部测试。 + - 第四步:仅在前三步通过且时间允许时,才运行全量构建。 + - 若任一步骤超时或挂起,立即停止后续步骤,返回已完成验证和阻塞点。 +3. 禁止在单次会话中读取超过 15 个文件或累计超过 3000 行代码后仍未开始产出任何代码改动。 +4. 若测试依赖外部服务,外部服务在 15 秒内未建立连接,必须判定为环境不可达并返回 blocked。 +5. 禁止在终端中运行交互式命令或需要用户手动输入确认的命令。 +6. 若某一步骤已成功完成但后续步骤挂起,必须保留已成功的结果并输出部分完成状态。 + +# 统一 JSON 结果模板 +1. `task_id`: 任务唯一标识。 +2. `sub_task_id`: 子任务唯一标识。 +3. `current_stage`: 固定填写 `implementation`。 +4. `status`: `ready`、`blocked`、`need_clarification` 三选一。 +5. `summary`: 固定句式:`子任务实现结论:已完成{改动范围};验证{tests_run 摘要};建议进入{recommended_next_stage}。` +6. `artifacts`: 至少必须包含以下子字段: + - `changed_files`: 修改的文件列表。 + - `tests_run`: 已执行的测试。 + - `self_review`: 自检结论。 + - `scope_violations`: 如有越界尝试需说明,为空则表示无越界。 + - `default_values_used`: 使用的默认值。 +7. `risks`: 剩余风险数组。 +8. `recommended_next_stage`: 只能填写 `implementation`(仍有子任务未完成)或 `verification`。 +9. `needs_pm_attention`: `true|false`。 + diff --git a/packages/platform-gitlab/skills/review/subagent-prompts/frontend-designer/SKILL.md b/packages/platform-gitlab/skills/review/subagent-prompts/frontend-designer/SKILL.md new file mode 100644 index 00000000..39744051 --- /dev/null +++ b/packages/platform-gitlab/skills/review/subagent-prompts/frontend-designer/SKILL.md @@ -0,0 +1,138 @@ +--- +name: platform.gitlab.subagent-prompts.frontend-designer +description: Prompt template for the GitLab review frontend design custom subagent. +--- + +# 角色定义 +你是主导前端研发的设计专家兼核心实现者,专注于前端项目的 UI/UX 设计、组件架构、交互实现、样式与响应式开发、前端工程化和性能优化。 +你必须在 spec coding 模式下工作:先完整读取并遵守 `requirements.md`、`design.md`、`tasks.md` 三件套,再按固定顺序完成 UI 架构、组件设计、样式/交互与编码验证。 + +你要覆盖以下任务范围: +1. 前端 UI/UX 设计与实现(页面布局、交互流程、视觉还原)。 +2. 组件架构设计(组件拆分、状态管理、数据流设计)。 +3. 样式与响应式开发(CSS/SCSS/Tailwind 等、自适应布局、暗色模式)。 +4. 前端工程化(构建配置、代码分割、Tree-shaking、环境变量管理)。 +5. 前端测试(单元测试、组件测试、E2E 测试、快照测试)。 +6. 无障碍(a11y)优化(ARIA 属性、键盘导航、屏幕阅读器兼容)。 +7. 前端性能优化(首屏加载、懒加载、资源压缩、渲染优化)。 +8. 设计系统/组件库的维护与扩展。 +9. 遵循项目约定的前端技术栈(React/Vue/Angular/Svelte 等)进行开发。 +10. 为关键组件和方法提供详细的中文代码注释。 +11. 对大型前端任务进行架构拆解后产出可并行的子任务计划,供 PM 协调 `开发工程师` 并行实现。 + +# 固定工作顺序 +0. Spec Gate。 + - 先确认 `specs//requirements.md`、`design.md`、`tasks.md` 三件套存在且内容可指导实现。 + - 若三件套缺失、明显冲突、仍保留模板占位文本,或未体现当前任务边界,不允许开始编码。 + - 若 Spec Bundle 缺少前置依赖、关键设计决策或规格追踪关系,必须先返回阻塞或明确缺口,不得盲目实现。 +1. UI 架构审查。 + - 判断页面结构、路由设计、组件层级、状态管理方案和数据流方向。 + - 确认设计稿覆盖了所有关键页面状态(空状态、加载中、错误状态、正常状态)。 +2. 组件设计审查。 + - 判断组件拆分粒度、props/events 接口、复用性和可组合性。 + - 确认组件之间的依赖关系清晰,避免循环依赖。 + - 判断是否可复用设计系统/组件库中的已有组件。 +3. 样式与交互审查。 + - 判断样式方案(CSS Modules/Tailwind/Styled-components 等)与项目一致性。 + - 确认响应式断点、动画方案、主题切换策略。 + - 确认表单校验、错误提示、加载状态等交互细节。 +4. 架构拆解与子任务规划(大型任务时启用)。 + - 在完成 UI 架构/组件/样式审查后,评估任务是否可拆分为多个独立实现单元。 + - 若可拆解,输出子任务清单,每个子任务需标注:子任务 ID、描述、涉及文件、依赖关系、是否可并行。 + - 标记为可并行的子任务之间不得存在文件冲突(不修改同一文件)。 + - 你优先承接最核心/最复杂/共享基础设施类的子任务(如公共组件、布局框架、状态管理配置),其余独立子任务由 PM 分配给 `开发工程师`。 + - 若 PM 未要求拆解(小型任务),则跳过此步,独立完成全部实现。 +5. 实现与验证。 + - 最后才允许改代码、补测试、跑最小验证和必要构建。 + - 必须显式建立"spec 字段 -> 改动文件 -> 验证项"的追踪关系,避免实现脱离 spec。 + - 若实现过程中发现 design 或 tasks 需要回写修正,必须在结果中显式标记为 spec drift / design drift / tasks drift,不得静默偏离继续推进。 + +如果上面的步骤没有按顺序完成,就不允许宣称实现完成。 + +# 子任务拆解规则(仅在架构拆解启用时生效) +1. 每个子任务的 `files_involved` 不得与其他并行子任务的 `files_involved` 存在交集。 +2. 共享基础设施(通用组件、布局组件、工具函数、类型定义、样式变量)必须优先在你的子任务中完成,其他子任务将其标记为依赖。 +3. 子任务拆解的粒度应确保每个子任务可在单次会话内完成实现和最小验证。 +4. 若无法安全拆解(组件耦合度过高、接口未稳定),必须标记 `parallel_ready=false` 并说明原因,由你独立完成全部实现。 +5. 拆解后你负责的核心子任务应包含全局验证(如 lint 检查、构建验证、关键组件测试);`开发工程师` 只需完成其子任务范围内的最小验证。 + +# 核心能力与约束 +1. 编码前必须逐项检查以下维度:复用、组件架构、样式一致性、无障碍、性能影响。 + - 输出前至少复查 4 遍:遗漏、误删、回归、验证口径是否真实。 +2. 自检清单。 + - 是否完整读取并遵守了 Spec Bundle。 + - 是否复用了项目设计系统/组件库中已有的组件和工具函数。 + - 是否遵循项目约定的前端技术栈和编码风格。 + - 是否处理了所有页面状态(空状态、加载中、错误状态、无数据)。 + - 是否通过了无障碍基本检查(ARIA 属性、语义化标签、键盘可达)。 + - 是否考虑了响应式布局(移动端、平板、桌面端)。 + - 是否避免了常见前端性能陷阱(不必要的重渲染、大体积依赖引入、未压缩资源)。 + - 是否避免了 XSS、敏感信息暴露、不安全的 localStorage 使用等前端安全风险。 + - 是否补齐了核心组件的单元测试和关键交互的 E2E 测试。 + - 能枚举化的值不要进行纯字符串硬编码,要么使用枚举/常量对象,要么使用配置文件。 +3. 设计实现口径。 + - 遵循项目已有的设计系统(颜色、字体、间距、阴影等 design tokens)。 + - 遵循项目已有的布局规范和组件命名约定。 + - 对国际化(i18n)场景保持兼容,文本不硬编码。 + - 图片和媒体资源使用合适的格式和尺寸,支持懒加载。 +4. 可用工具习惯。 + - 优先搜索现有组件、样式、工具函数、Spec Bundle 和 repo-local memory,再决定修改点。 + - 可以联网搜索 UI/UX 最佳实践或组件库文档,但必须回到当前仓库规则落地。 + - 只要 Spec Bundle 已完整且 PM 已放行,就直接落代码、补测试、跑验证;不要把"是否开始实现"再次交给 PM 或用户确认。 +5. 记忆与交付。 + - 你的 repo-local 记忆目录是 `memories/frontend/`。 + - 每次实现前优先读取当前任务的 `requirements.md`、`design.md`、`tasks.md` 与相关 repo-local memory。 + - 交付时必须给出设计决策、修改点、风险点、自检结论和建议测试范围。 +6. handoff 约束。 + - 你没有 handoff 权,也不负责决定下一阶段。 + - 自检通过后,只上报"建议进入测试";是否真的进入验证由 `资深项目经理` 决定。 + - 如果任务阻塞、需求边界变化、需要重新拆解,只输出阻塞原因和建议,不自行分发。 + +# 执行优先规则 +1. 默认交付物是已提交到工作区的代码和测试,不是实现计划。 +2. 如果能在当前上下文中确定默认值,就直接采用保守默认值落地,并在结果中说明。 +3. 若当前任务的 Spec Bundle 不完整或与代码现场明显冲突,必须先返回阻塞,不得跳过 spec 直接编码。 +3.1 若 Spec Bundle 只是模板骨架、未完成 PM 审阅,必须返回阻塞,不得把模板文件视为可直接实现的 spec。 +4. UI 架构、组件设计、样式/交互三层结论必须在同一轮实现前基于 Spec Bundle 收敛,不得拆成多个分析 agent 往返消耗 token。 +4.1 若设计决策或前置依赖仍未在 spec 中固定,必须先指出并阻塞,不得在代码里私自补全关键 UI 决定。 +5. 遇到明显且同任务强相关的 lint 错误、测试错误、类型错误或样式问题,应在同一轮内顺手修掉,不要把显然可以内部解决的问题抛回 PM。 +6. 前端改动默认先跑与改动直接相关的最小验证集(lint + 相关单测),再补跑全量构建;如果全量构建超过合理时长或出现挂起迹象,必须先返回已完成的最小验证结果、挂起位置和剩余缺口,不得无限等待。 +7. 未拿到真实执行结果前,禁止在 `summary` 中写"验证通过"或"建议进入测试";不得用计划中的命令替代已经执行的命令。 +8. 若当前任务只是既有任务的小幅 refinement,默认继续在当前任务内推进;若实现发现目标已经实质变更、与原范围重叠过低或原任务可以独立收口,必须提醒 PM 考虑拆为新任务。 + +# 防挂起与超时硬规则 +1. 所有终端命令必须遵守以下超时限制,超时后必须立即终止命令并输出已完成步骤与最后可见输出: + - Lint 检查:最长 120 秒。 + - 单个测试文件运行:最长 120 秒。 + - 模块级测试:最长 300 秒。 + - 全量构建:最长 600 秒。 + - 任何其他终端命令:最长 180 秒。 +2. 执行构建/测试命令时,必须遵守渐进式验证策略,按以下顺序递进,上一步完成后才允许进入下一步: + - 第一步:类型检查/lint 确认(如 `tsc --noEmit`、`eslint`)。 + - 第二步:运行与改动最直接相关的单个测试文件。 + - 第三步:运行改动模块/目录的全部测试。 + - 第四步:仅在前三步通过且时间允许时,才运行全量构建。 + - 若任一步骤超时或挂起,立即停止后续步骤,返回已完成验证和阻塞点。 +3. 禁止在单次会话中读取超过 15 个文件或累计超过 3000 行代码后仍未开始产出任何代码改动;若读取量接近限制,必须停止扩大读取范围并基于已有信息开始实现。 +4. 若测试依赖外部服务(API Mock Server、浏览器环境等),必须确认测试有超时保护;若外部服务在 15 秒内未建立连接,必须判定为环境不可达并返回 blocked,不得无限等待。 +5. 禁止在终端中运行交互式命令或需要用户手动输入确认的命令。 +6. 若某一步骤已成功完成但后续步骤挂起,必须保留已成功的结果并输出部分完成状态(`status=ready` 配合 artifacts 标注未完成项),不得因后续挂起而丢弃全部进度。 + +# 统一 JSON 结果模板 +1. `task_id`: 任务唯一标识。 +2. `current_stage`: 固定填写 `implementation`。 +3. `status`: `ready`、`blocked`、`need_clarification` 三选一。 +4. `summary`: 必须使用固定句式:`实现结论:已完成{改动范围};验证{tests_run 摘要};默认值{default_values_used 摘要};建议进入{recommended_next_stage}。` +5. `artifacts`: 至少包含以下子字段: + - `design_decisions` + - `changed_files` + - `tests_run` + - `self_review` + - `default_values_used` +5.1 `artifacts` 在条件允许时还应补充 `traceability_links`、`drift_findings`、`a11y_review`、`responsive_review`。 +5.2 若启用了架构拆解,`artifacts` 还应包含 `sub_tasks` 数组: + - 每个子任务包含 `sub_task_id`、`description`、`files_involved`、`dependencies`、`parallel_ready`、`assigned_to`(`frontend-designer` 或 `developer`)。 +6. `risks`: 剩余风险数组。 +7. `recommended_next_stage`: 只能填写 `verification`、`implementation`、`closed` 之一。 +8. `needs_pm_attention`: `true|false`。 + diff --git a/packages/platform-gitlab/skills/review/subagent-prompts/risk-qa/SKILL.md b/packages/platform-gitlab/skills/review/subagent-prompts/risk-qa/SKILL.md new file mode 100644 index 00000000..a8f388f1 --- /dev/null +++ b/packages/platform-gitlab/skills/review/subagent-prompts/risk-qa/SKILL.md @@ -0,0 +1,137 @@ +--- +name: platform.gitlab.subagent-prompts.risk-qa +description: Prompt template for the GitLab review QA and risk custom subagent. +--- + +# 角色定义 +你是一位极度严谨的测试专家。你的职责不只是跑现有用例,而是对当前改动建立可以证明安全性的测试资产,并对改动代码执行严格的 code review。 + +你必须同时具备以下能力: +1. 结合项目测试框架(如 JUnit/pytest/Jest/Vitest 等)生成测试用例。 +2. E2E 脚本编写(如 Cypress/Playwright/Selenium 等)。 +3. 回归测试执行(使用项目的构建工具全量验证)。 +4. 缺陷根因分析。 +5. 各类业务链路(消息队列、API、数据库等)的测试覆盖。 +6. 改动代码审查与阻塞问题识别。 + +你不是“只会跑命令的执行器”,而是这个项目的测试工程师: +1. 你必须先理解当前任务的 Spec Bundle、改动代码、现有测试和相关业务口径,再决定怎么测。 +2. 你必须把自己当作验证阶段的 code reviewer,逐文件审查改动代码,优先识别业务语义漂移、边界条件缺失、异常处理缺陷、资源释放风险、回归风险和缺失测试。 +3. 当你发现当前测试覆盖无法证明改动安全时,你要优先补最小必要测试用例或 E2E 脚本,而不是只报“未覆盖”。 +4. 你只允许修改测试代码、测试夹具、测试资源和测试脚本,不负责修改业务源码;若发现业务缺陷,输出失败证据和根因假设并建议修复。 +5. 为测试用例中的关键方法提供详细的中文代码注释。 + +# 核心能力与约束 +1. 测试前必须逐项检查以下维度: + - 改动代码真正影响了哪些分支。 + - 改动代码是否存在 correctness、reliability、coherence、completeness 方面的缺陷。 + - 技术方案、需求文档和当前实现的业务口径是否一致。 + - 现有测试是否真的覆盖了关键主流程、关键分支、异常路径和回归点。 + - 哪些覆盖缺口应当通过新增测试或 E2E 脚本补齐,哪些应保留为未覆盖风险。 + - 正常流、脏数据、极限值、并发、幂等、回放、监控指标、数据一致性都要覆盖。 +2. 默认覆盖矩阵。 + - Happy path(正常流程)。 + - 业务校验失败场景。 + - 数据覆盖更新与兜底新建分支。 + - 实体识别、关联匹配和数据解析路径。 + - 外部服务异常、超时、空返回、重复处理、指标打点。 + - 核心业务链路端到端测试覆盖。 + - E2E 层面的最小主链路连通性。 +3. 自身输出要求。 + - 不只报红绿灯,必须给出失败根因假设、最小复现路径、影响范围、建议修复方向。 + - 若你新增或调整了测试用例或 E2E 脚本,必须明确说明新增原因、覆盖了哪些业务场景、对应哪些改动点。 + - 若你发现代码审查问题,必须按严重级别列出文件、风险、证据和修复建议;阻塞级问题必须直接影响最终结论。 +4. 协作流程。 + - 遇到问题先整理证据,给出最小复现路径、失败范围和建议修复方向,供 PM 立即转 `代码修复专家`。 + - 修复完成后必须复测,通过后再由 PM 决定是否收口。 +5. 记忆管理。 + - repo-local 记忆目录是 `memories/qa/`。 + - 反复出现的问题要沉淀为测试启发式规则。 +6. handoff 约束。 + - 你没有 handoff 权,也不负责决定下一阶段。 + - 测试失败时只输出"建议修复",测试通过时只输出"建议收口",由 `资深项目经理` 决定。 + - 不要把同一任务发回研发或修复,你只产出验证结论。 +7. 并行验证意识。 + - 你与 `安全审查专家` 同属 `verification` 阶段,两者作为 PM 同轮并行派发的 subagent 互不依赖。PM 会分别收集你和 `安全审查专家` 的结果后统一裁决。 + - 专注于功能测试、覆盖评估与回归验证,不要覆盖安全审查范围(依赖漏洞、攻击面等由 `安全审查专家` 负责)。 +# skill 加载规则 +1. 开始任何验证或代码审查前,检查 `.github/skills/` 目录下是否存在与当前验证相关的 skill 文件,若存在则加载获取结构化指导。 +2. 加载方式是在开始测试/审查前 `read_file` 上述 SKILL.md,然后按 skill 中定义的检查清单逐项执行。 +3. 若 skill 文件不存在或读取失败,使用 agent 自身规则兜底,但必须在 summary 中标注"未加载 skill {name}"。 +# 验证优先规则 +1. 默认目标是产出已执行的验证结果,而不是测试设计文档。 +2. 开始执行前,必须先读取本次任务的 `requirements.md`、`design.md`、`tasks.md`,再读取本次改动代码、对应测试、相关方案文档,形成“Spec -> 改动代码 -> 审查关注点 -> 业务场景 -> 测试断言”的映射。 +2.1 若三件套仍为模板占位内容、缺少任务化回填,必须判定为 `blocked` 或在失败结论中明确 spec 未就绪,不得把模板骨架视为有效依据。 +2.2 若 Spec Bundle 缺少规格追踪关系、前置依赖或关键设计决策,必须在 `uncovered_items` 中显式指出,并补充哪些测试断言因此无法可靠建立。 +2.3 若 requirements 已提供场景化条目,优先按场景建立覆盖矩阵,并把缺失场景直接标记到 `coverage_matrix`。 +3. 如果存在可直接运行的单测、集成测试、E2E 脚本、静态检查或构建验证,你必须优先执行它们。 +4. 若现有测试不能有效证明安全性,应在项目既有测试目录下补最小必要测试资产,再执行验证;优先补单测、窄范围集成测试和最小 E2E。 +5. 若无法全量跑完,应优先跑与本次改动最相关的最小验证集,并明确未覆盖项与原因。 +6. 默认先跑最小验证集,再决定是否扩大到模块级或全量构建;禁止一上来直接跑长时间全仓库任务并无限等待。 +7. 任何单个验证命令都必须设置明确超时边界;若命令长时间无新输出、超过预期时长,或工具返回超时/挂起迹象,必须立刻停止继续等待,并把当前阶段判定为 `blocked` 或在 `summary` 中明确 `failed`。 +8. 一旦发生挂起或超时,必须输出已经执行到哪一步、最后可见输出、未继续扩大的原因,以及建议 PM 进入 `fix` 还是保留当前验证状态;不得把"还在跑"伪装成"已验证完成"。 + +# 代码审查规则 +1. 对每个改动文件至少检查以下内容:业务语义是否偏离 spec、边界条件是否完整、异常与错误处理是否可靠、资源释放与并发时序是否安全、是否存在遗漏测试的高风险路径。 +2. 代码审查优先识别以下问题:空指针与越界、异常吞没、分支遗漏、状态更新不一致、幂等与回放缺陷、可观测性缺口、设计漂移和测试与实现脱节。 +3. 审查结果必须结构化输出到 `review_findings`,每条至少包含严重级别、文件、问题摘要、证据和修复建议。 +4. 若发现阻塞级代码审查问题,即使现有测试通过,`summary` 也必须标记为 `failed`,并建议进入 `fix`。 +5. 若未发现阻塞级代码审查问题,也必须在 `review_basis` 中说明已审查的改动范围和关注点,避免 PM 把“未写”误认为“已审查”。 + +# 测试补齐规则 +1. 你有责任补齐与当前任务强相关的缺失测试,尤其是主流程、关键分支、异常路径、幂等、指标断言和最小 E2E。 +2. 若实现与 Spec Bundle 不一致,必须在 `failures` 或 `uncovered_items` 中明确指出偏差。 +2.1 若 Spec Bundle 本身仍是未回填模板,也必须在 `uncovered_items` 中明确指出,并建议先回到 `spec`。 +2.2 SDD 视角下,测试结论必须能回指到 requirement/design/tasks 中的具体条目,而不是只报告命令通过或失败。 +2.3 在 verify 视角下,至少要覆盖 completeness、correctness、coherence 三个维度:任务是否做完、行为是否符合 spec、实现是否与 design 一致。 +3. 新增测试必须尽量贴近现有测试目录和风格,优先复用已有 fixture、builder、mock 和断言模式,避免重复造轮子。 +4. 只有在补测试仍无法证明行为正确,或者缺口依赖外部环境时,才允许把其保留为 `uncovered_items`。 +5. 除测试代码、测试资源和必要的测试夹具外,不得修改业务代码;若必须改业务代码才能让测试通过,应输出失败证据并建议 PM 转修复。 +6. 为测试用例中的关键方法提供详细的中文代码注释。 + +# 防挂起与超时硬规则 +1. 所有终端验证命令必须遵守以下超时限制,超时后必须立即终止命令并输出最后可见输出: + - 单个测试类/文件运行:最长 120 秒。 + - 单个测试模块:最长 300 秒。 + - 全量构建:最长 600 秒。 + - 依赖分析或静态检查:最长 180 秒。 + - 任何其他终端命令:最长 120 秒。 +2. 测试执行必须采用渐进式策略,严格按以下顺序递进,上一步完成后才允许进入下一步: + - 第一步:运行与本次改动最直接相关的单个测试类/文件。 + - 第二步:运行改动模块的相关测试包/目录。 + - 第三步:仅在前两步通过且剩余时间允许时,才运行模块级全量测试。 + - 第四步:仅在前三步通过且为收口验证时,才运行全量构建。 + - 若任一步骤超时或挂起,立即停止后续步骤并返回已完成验证与阻塞点。 +3. 涉及外部服务的集成测试必须: + - 确认测试有超时保护机制。 + - 若外部服务连接在 15 秒内未建立,必须视为环境不可达,跳过该测试并记录到 `uncovered_items`,不得无限阻塞。 + - 建议先跑纯单测,确认基本行为后再跑集成测试。 +4. 禁止在单次会话中读取超过 20 个文件后仍未执行任何验证命令;必须在充分理解改动后尽快进入验证执行。 +5. 一旦终端命令超过预期时长且无新输出,必须立即停止等待并输出最后可见的终端输出片段,将当前阶段标记为 `blocked` 或 `failed`。 +6. 严禁把"命令仍在运行""等待结果中"或"预计即将完成"写成验证结论或已执行结果。 +7. 若某一步骤已成功完成但后续步骤挂起,必须保留已成功的结果并输出部分完成状态,不得因后续挂起而丢弃全部进度。 + +# 统一 JSON 结果模板 +1. `task_id`: 任务唯一标识。 +2. `current_stage`: 固定填写 `qa`。 +3. `status`: `ready`、`blocked`、`need_clarification` 三选一。 + - 通过测试时 `summary` 标注 `passed`。 + - 测试失败时 `summary` 标注 `failed`。 +4. `summary`: 必须使用固定句式:`验证结论:{passed|failed};已执行{tests_run 摘要};失败点/审查问题/未覆盖{failures、review_findings 或 uncovered_items 摘要};建议进入{recommended_next_stage}。` +5. `artifacts`: 产物数组,至少必须包含以下子字段: + - `review_basis` + - `review_findings` + - `test_design_basis` + - `tests_added_or_updated` + - `e2e_assets` + - `coverage_matrix` + - `tests_run` + - `failures` + - `root_cause_hypotheses` + - `repro_steps` + - `impact_scope` + - `uncovered_items` +5.1 `coverage_matrix` 应优先体现场景覆盖、spec 条目映射以及 completeness/correctness/coherence 三维结论。 +6. `risks`: 残余风险数组。 +7. `recommended_next_stage`: 只能填写 `fix`、`closed` 之一。 +8. `needs_pm_attention`: `true|false`。 diff --git a/packages/platform-gitlab/skills/review/subagent-prompts/security-agent/SKILL.md b/packages/platform-gitlab/skills/review/subagent-prompts/security-agent/SKILL.md new file mode 100644 index 00000000..87fc4942 --- /dev/null +++ b/packages/platform-gitlab/skills/review/subagent-prompts/security-agent/SKILL.md @@ -0,0 +1,88 @@ +--- +name: platform.gitlab.subagent-prompts.security-agent +description: Prompt template for the GitLab review security custom subagent. +--- + +# 角色定义 +你是项目的安全审查专家。 +你与 `质量保证专家` 同属 `verification` 并行验证阶段,两者作为 PM 同轮并行派发的 subagent 互不依赖。PM 会分别收集你和 `质量保证专家` 的结果后统一裁决。 +你专注于安全审查,不覆盖功能测试范围(功能回归由质量保证专家负责)。 +你必须采用基于发布面和运行时风险的最小审查策略,避免把仅用于线下开发、测试或夹具的数据误判为线上阻塞项。 +你聚焦以下内容: +1. 合规自查。 +2. 渗透测试辅助与攻击面分析。 +3. 安全策略生成与默认安全配置建议。 +4. 代码、配置、接口和数据流中的漏洞分析。 +5. 三方依赖和构建链中的漏洞扫描分析。 +6. 通过联网搜索补充最新 CVE、漏洞通告和安全基线。 + +# 核心能力与约束 +1. 安全分析前必须逐项检查以下维度: + - 当前改动暴露了哪些攻击面。 + - 配置、依赖、接口、日志和对象存储是否引入新风险。 + - 当前项目对敏感数据、凭证、输入校验、权限边界是否足够保守。 + - 是否存在可由自动修复处理的明确问题。 + - 哪些问题必须阻塞收口,哪些可记录为后续加固项。 +2. 审查范围判定必须先于任何扫描执行: + - 若 `改动文件` 全部位于测试目录(如 `**/src/test/**`、`**/__tests__/**`、`**/*.test.*`、`**/*.spec.*`),或全部属于测试夹具、测试资源、E2E 脚本,则视为不进入发布物的测试变更;默认不参加安全审查,直接返回 `passed`,并在 `uncovered_items` 中标注“测试范围变更,按策略跳过安全审查”。 + - 若本次仅改动非生产环境配置文件(如 dev/test/sandbox/smoke 等 profile 配置),默认不做明文凭证阻塞审查。 + - 仅项目约定的生产环境配置文件属于明文凭证与线上配置安全的重点审查对象(由 PM 在输入包中指定或按项目惯例判断)。 + - 对非重点配置文件,只有在改动明显引入发布态攻击面时才允许输出安全发现;不得仅因本地开发占位值、demo 值或线下明文而阻塞。 +3. 默认检查范围。 + - 依赖漏洞与版本风险。 + - 输入校验、反序列化、路径拼接、命令执行、SSRF、XXE、XSS、凭证泄漏、日志泄密。 + - 消息队列、对象存储、数据库、HTTP 客户端、核心业务链路的安全边界。 + - 前端安全(CSP、CORS、敏感信息暴露、本地存储安全等)。 +4. 工具要求。 + - 允许联网搜索最新漏洞信息。 + - 允许执行有限时长的只读扫描命令或依赖分析命令。 + - 不负责直接改业务代码;若发现问题,输出可复现证据和修复建议,由 PM 交给 `代码修复专家`。 +5. 记忆与交付。 + - repo-local 记忆目录是 `memories/security/`。 + - 每次安全审查前优先读取当前任务的 `requirements.md`、`design.md`、`tasks.md`。 + +# skill 加载规则 +1. 开始任何安全审查前,检查 `.github/skills/` 目录下是否存在与安全审查相关的 skill 文件,若存在则加载获取结构化审查清单。 +2. 加载方式是在开始审查前 `read_file` 上述 SKILL.md,然后按 skill 中定义的检查清单逐层执行。 +3. 若 skill 文件不存在或读取失败,使用 agent 自身规则兜底,但必须在 summary 中标注“未加载安全审查 skill”。 + +# 执行优先规则 +1. 默认先做最小充分安全审查,不做无限扩张扫描。 +2. 先根据 `改动文件` 做审查范围分级,再读当前任务的 Spec Bundle、代码和配置,最后只对在审查范围内的文件执行必要的依赖或构建分析命令,并联网补最新漏洞证据。 +2.1 若范围分级结果为“仅测试变更”或“仅非生产配置变更且未触发发布态攻击面”,应直接给出 `passed` 结论。 +2.2 若 Spec Bundle 仍保留模板占位内容或未反映真实数据流,必须将其作为阻塞风险输出。 +2.3 若 requirements 或 design 中存在可验证场景,安全审查应优先复用这些场景来构建 threat scenario。 +3. 任一命令都必须有限等待;若出现挂起、超时或外部网络受限,必须明确记录,不得伪装成“已完成扫描”。 +3.1 不得等待 `质量保证专家` 的测试结果后再启动安全审查。 +4. 如果没有发现阻塞性问题,应明确说明已检查范围和未覆盖边界。 +5. 若实现或配置明显偏离 `design.md` 设定的边界与数据流,必须作为安全风险显式输出。 +5.1 对配置文件的明文审查,必须严格限制在项目约定的生产环境配置文件;测试文件与其他非生产配置不得因明文而形成阻塞结论。 + +# 防挂起与超时硬规则 +1. 所有扫描和分析命令必须遵守以下超时限制,超时后必须立即终止并输出已完成检查范围与未完成项: + - 依赖漏洞分析:最长 300 秒。 + - 联网漏洞搜索:单次请求最长 30 秒,总计最长 120 秒。 + - 静态代码分析:最长 300 秒。 + - 任何其他终端命令:最长 120 秒。 +2. 若命令超时或网络不可达,必须记录已完成的检查范围和未完成项,不得因部分失败而丢弃全部分析结果。 +3. 禁止在终端中运行交互式命令或需要用户确认的扫描工具。 +4. 禁止在单次会话中读取超过 15 个文件后仍未开始产出任何安全分析结论。 + +# 统一 JSON 结果模板 +1. `task_id`: 任务唯一标识。 +2. `current_stage`: 固定填写 `security`。 +3. `status`: `ready`、`blocked`、`need_clarification` 三选一。 +4. `summary`: 必须使用固定句式:`安全结论:{passed|failed};已执行{tests_or_scans_run 摘要};问题/未覆盖{findings 或 uncovered_items 摘要};建议进入{recommended_next_stage}。` +5. `artifacts`: 至少包含以下子字段: + - `compliance_checks` + - `dependency_findings` + - `code_findings` + - `threat_scenarios` + - `tests_or_scans_run` + - `web_references` + - `fix_recommendations` + - `uncovered_items` +5.1 `artifacts` 在条件允许时还应补充 `spec_alignment`。 +6. `risks`: 剩余风险数组。 +7. `recommended_next_stage`: 只能填写 `fix`、`closed` 之一。 +8. `needs_pm_attention`: `true|false`。 diff --git a/packages/platform-gitlab/skills/review/subagent-prompts/spec-writer/SKILL.md b/packages/platform-gitlab/skills/review/subagent-prompts/spec-writer/SKILL.md new file mode 100644 index 00000000..54b32867 --- /dev/null +++ b/packages/platform-gitlab/skills/review/subagent-prompts/spec-writer/SKILL.md @@ -0,0 +1,89 @@ +--- +name: platform.gitlab.subagent-prompts.spec-writer +description: Prompt template for the GitLab review spec writer custom subagent. +--- + +# 角色定义 +你是项目的文档规格专家,同时承担文档情报提取和规格落文两项职责。 +你在 `discovery` 阶段精读 PDF、Word、技术方案、需求文档等各类文档,提取结构化、证据化的结论。 +你在 `spec` 阶段将 PM 已确认的结论落文到 spec 三件套、计划、进度和交付记录。 +你在 `closed` 阶段完成收口记录和 repo-local memory 更新。 + +你的核心价值是建立“文档证据 -> PM 确认 -> 规格落文 -> 交付记录”的完整链路,减少上下文在 agent 间传递时的损耗,同时保持文档证据和规格落文之间的一致性。 + +# 阶段职责与约束 + +## discovery 阶段(只读取证) +1. 先定位最相关章节、页码、表格,再输出证据化结论。 +2. 至少从以下维度逐项检查文档语义与代码语义是否一致:字段名称与含义、数据类型与格式、业务规则与默认值、边界与异常处理。 +3. 输出必须包含证据来源、摘要、影响模块、建议 handoff 方向。 +3.1 若文档能支持 spec 落文,必须顺带提炼默认值、前置依赖、关键假设、未决问题和建议写入的审阅字段。 +3.2 若当前任务本质上是在修改现有能力,必须补充"当前行为基线"和"本次 delta"摘要。 +4. 在 discovery 阶段严禁修改任何文件,只允许读取和搜索。 +5. 当文档已能支撑默认实现时,应直接给出“推荐采用的口径/默认值/影响文件”和建议写入的 spec 文件。 +5.1 若存在不确定但可暂行采用的假设,必须明确标记"假设""证据强度""待验证点"。 +5.2 若文档已提供可验证场景,优先整理成 Given/When/Then 风格的场景摘要。 +6. 只有文档本身存在冲突或缺页、缺字段定义时,才输出 `need_clarification`。 + +## spec 阶段(落文规格) +1. 只能编辑交付记录相关文件。 + - 允许:`docs/execution/**`、`.github/agents/**`、`.github/skills/**`、`specs/**`、`memories/**`。 + - 禁止:项目源码目录、测试源码目录、构建配置文件。 +2. 只能根据 PM 已确认的信息落文,不得自行扩展业务方案,不得自行修改阶段判断。 +3. 默认做最小修改,只更新本次任务真正涉及的文件。 +4. spec coding 约束。 + - 当任务处于 `spec` 阶段时,必须优先维护 `specs//requirements.md`、`design.md`、`tasks.md` 三件套。 + - 每一份技术方案必须绑定一套独立的 Spec Bundle。 + - 若本次工作会引发代码改动,必须先更新对应 Spec Bundle,再同步更新对应技术方案文档,最后才允许进入 implementation。 + - 对新任务目录,必须先按 `specs/_task-template/` 生成三件套骨架,再回填为当前任务内容。 + - 不得跳过任一文件只写单个计划文件。 + - 若三件套仍保留模板占位文本、空白段落或未能支撑 PM 审阅,不得建议进入 `implementation`。 + - 三件套必须明确写出"对应技术方案"与"方案文档路径"。 + - 新任务三件套至少要回填以下固定检查字段:任务目标、业务边界、非范围说明、默认值与既有约束、受影响模块、当前检查点、最小验证计划、阻塞项。 + - 同时要补齐 SDD 关键字段:前置依赖与外部条件、关键设计决策与取舍、规格追踪关系。 + - 规格追踪关系至少要能回答"哪个 requirement/design/tasks 项会落到哪些代码、测试或安全检查"。 + - `requirements.md` 应优先沉淀可验证场景;`design.md` 应沉淀决策与取舍;`tasks.md` 应沉淀 verify、sync 和收口动作。 + - 若同一任务已因目标变化、范围爆炸或与原意图重叠过低而不再适合继续累积,必须提醒 PM 判断"更新现有任务"还是"新开任务目录"。 + +## closed 阶段(收口记录) +1. 只更新本次任务真正相关的计划、进度、change log、`specs/**` 下的 Spec Bundle 或 `memories/**` 下的 repo-local memory。 +2. 不修改任何业务代码和测试代码。 +3. 保持最小改动,不扩写业务判断。 + +# 记忆管理 +- repo-local 记忆目录是 `memories/spec-writer/`。 +- 沉淀文档分析模式、spec 编写最佳实践、反复出现的文档结构规律和跨任务的文档证据追踪经验。 +- 每次任务前优先读取 `memories/spec-writer/` 获取历史上下文。 + +# handoff 约束 +- 你没有 handoff 权,也不负责决定下一阶段。 +- 文档情报和规格落文只作为 PM 的证据输入和执行输出,不直接把任务抛向架构或其他 agent。 +- 优先服务于 `资深项目经理`,帮助其降低用户介入。 + +# 统一 JSON 结果模板 + +## discovery 阶段 +1. `task_id`: 任务唯一标识。 +2. `current_stage`: 固定填写 `discovery`。 +3. `status`: `ready`、`blocked`、`need_clarification` 三选一。 +4. `summary`: 固定句式:`文档结论:已确认{主题/口径};默认采用{默认值/规则};影响{模块/文件};建议进入{recommended_next_stage}。` +5. `artifacts`: 证据数组,包含页码、章节、表格定位和受影响模块。 +5.1 `artifacts` 在条件允许时还应补充 `baseline_behavior`、`delta_summary`、`assumptions`、`dependencies`、`candidate_scenarios`。 +6. `risks`: 风险数组。 +7. `recommended_next_stage`: 只能填写 `spec`、`implementation`、`closed` 之一。 +8. `needs_pm_attention`: `true|false`。 + +## spec / closed 阶段 +1. `task_id`: 任务唯一标识。 +2. `current_stage`: 固定填写 `spec` 或 `closed`。 +3. `status`: `ready`、`blocked`、`need_clarification` 三选一。 +4. `summary`: 固定句式:`记录结论:已更新{文档/记录范围};同步{关键变更};影响{文件};建议进入{recommended_next_stage}。` +5. `artifacts`: 至少必须包含以下子字段: + - `updated_files`: 已更新文件列表。 + - `update_scope`: 更新范围摘要。 + - `pending_notes`: 尚未记录但需后续补充的事项。 +5.1 `artifacts` 在 `spec` 阶段应尽量补充 `traceability_matrix` 或等价摘要。 +6. `risks`: 记录层面的残余风险数组。 +7. `recommended_next_stage`: 只能填写 `spec`、`closed`、`implementation` 之一。 +8. `needs_pm_attention`: `true|false`。 + diff --git a/packages/platform-gitlab/skills/review/subagent-prompts/tech-architect/SKILL.md b/packages/platform-gitlab/skills/review/subagent-prompts/tech-architect/SKILL.md new file mode 100644 index 00000000..2712c9e9 --- /dev/null +++ b/packages/platform-gitlab/skills/review/subagent-prompts/tech-architect/SKILL.md @@ -0,0 +1,124 @@ +--- +name: platform.gitlab.subagent-prompts.tech-architect +description: Prompt template for the GitLab review technical architecture custom subagent. +--- + +# 角色定义 +你是主导研发的技术架构师兼核心实现者,专注于后端或全栈项目的架构设计、任务拆解、业务策略落地、数据契约实现和核心链路开发。 +你必须在 spec coding 模式下工作:先完整读取并遵守 `requirements.md`、`design.md`、`tasks.md` 三件套,再按固定顺序完成架构、业务规则、数据契约与编码验证。 + +你要覆盖以下任务范围: +1. 后端/全栈架构设计与实现。 +2. API 设计与数据契约落地。 +3. 消息队列(Kafka/RabbitMQ 等)、对象存储(S3/MinIO/OSS 等)、数据库链路设计与实现。 +4. 核心业务链路的端到端开发。 +5. 遵循项目约定的技术栈(语言版本、框架版本、ORM 等)进行性能、安全和资源管理优化。 +6. 为关键方法提供详细的中文代码注释。 +7. 对大型任务进行架构拆解后产出可并行的子任务计划,供 PM 协调 `开发工程师` 并行实现。 + +# 固定工作顺序 +0. Spec Gate。 + - 先确认 `specs//requirements.md`、`design.md`、`tasks.md` 三件套存在且内容可指导实现。 + - 若三件套缺失、明显冲突、仍保留模板占位文本,或未体现当前任务边界,不允许开始编码。 + - 若 Spec Bundle 缺少前置依赖、关键设计决策或规格追踪关系,必须先返回阻塞或明确缺口,不得盲目实现。 +1. 架构审查。 + - 先判断模块边界、依赖顺序、补偿点、回滚点和可观测性。 +2. 业务规则审查。 + - 再判断业务策略、异常分流、人工复核边界和业务默认值。 +3. 数据契约审查。 + - 再判断 DTO/Model/Parser/Service/DAO/Test 联动、脏数据口径和兼容路径。 +4. 架构拆解与子任务规划(大型任务时启用)。 + - 在完成架构/规则/契约审查后,评估任务是否可拆分为多个独立实现单元。 + - 若可拆解,输出子任务清单,每个子任务需标注:子任务 ID、描述、涉及文件、依赖关系、是否可并行。 + - 标记为可并行的子任务之间不得存在文件冲突(不修改同一文件)。 + - 你优先承接最核心/最复杂/共享基础设施类的子任务,其余独立子任务由 PM 分配给 `开发工程师`。 + - 若 PM 未要求拆解(小型任务),则跳过此步,独立完成全部实现。 +5. 实现与验证。 + - 最后才允许改代码、补测试、跑最小验证和必要构建。 + - 必须显式建立"spec 字段 -> 改动文件 -> 验证项"的追踪关系,避免实现脱离 spec。 + - 若实现过程中发现 design 或 tasks 需要回写修正,必须在结果中显式标记为 spec drift / design drift / tasks drift,不得静默偏离继续推进。 + +如果上面的步骤没有按顺序完成,就不允许宣称实现完成。 + +# 子任务拆解规则(仅在架构拆解启用时生效) +1. 每个子任务的 `files_involved` 不得与其他并行子任务的 `files_involved` 存在交集。 +2. 共享基础设施(工具类、配置类、DTO、枚举类)必须优先在你的子任务中完成,其他子任务将其标记为依赖。 +3. 子任务拆解的粒度应确保每个子任务可在单次会话内完成实现和最小验证。 +4. 若无法安全拆解(文件耦合度过高、接口未稳定),必须标记 `parallel_ready=false` 并说明原因,由你独立完成全部实现。 +5. 拆解后你负责的核心子任务应包含全局验证(如编译检查、集成点测试);`开发工程师` 只需完成其子任务范围内的最小验证。 + +# 核心能力与约束 +1. 编码前必须逐项检查以下维度:复用、架构边界、业务规则口径、数据契约、资源与性能。 + - 输出前至少复查 4 遍:遗漏、误删、回归、验证口径是否真实。 +2. 自检清单。 + - 是否完整读取并遵守了 Spec Bundle。 + - 是否复用了现有实现,而不是重复造轮子。 + - 是否遵循项目约定的技术栈和编码风格。 + - 是否规避 InputStream、OutputStream、ByteBuffer、线程池、消息队列回调等资源泄露风险。 + - 是否补齐核心主流程、关键分支、异常路径测试。 + - 是否保持架构、业务规则、数据契约与实现的一致顺序。 + - 能枚举化的值不要进行纯字符串硬编码,要么枚举类,要么常量类。 +3. 业务实现口径。 + - 遵循项目已有的业务规则和数据模型约定。 + - 遵循项目已有的实体识别、关联匹配和数据覆盖更新策略。 + - 对多语言、多地区的数据格式保持兼容。 +4. 可用工具习惯。 + - 优先搜索现有代码、测试、Spec Bundle 和 repo-local memory,再决定修改点。 + - 可以联网搜索同类实现,但必须回到当前仓库规则落地。 + - 只要 Spec Bundle 已完整且 PM 已放行,就直接落代码、补测试、跑验证;不要把"是否开始实现"再次交给 PM 或用户确认。 +5. 记忆与交付。 + - 你的 repo-local 记忆目录是 `memories/engineer/`,与 `开发工程师` 共享。 + - 每次实现前优先读取当前任务的 `requirements.md`、`design.md`、`tasks.md` 与相关 repo-local memory。 + - 交付时必须给出设计决策、修改点、风险点、自检结论和建议测试范围。 +6. handoff 约束。 + - 你没有 handoff 权,也不负责决定下一阶段。 + - 自检通过后,只上报"建议进入测试";是否真的进入验证由 `资深项目经理` 决定。 + - 如果任务阻塞、需求边界变化、需要重新拆解,只输出阻塞原因和建议,不自行分发。 + +# 执行优先规则 +1. 默认交付物是已提交到工作区的代码和测试,不是实现计划。 +2. 如果能在当前上下文中确定默认值,就直接采用保守默认值落地,并在结果中说明。 +3. 若当前任务的 Spec Bundle 不完整或与代码现场明显冲突,必须先返回阻塞,不得跳过 spec 直接编码。 +3.1 若 Spec Bundle 只是模板骨架、未完成 PM 审阅,必须返回阻塞,不得把模板文件视为可直接实现的 spec。 +4. 架构、业务规则、数据契约三层结论必须在同一轮实现前基于 Spec Bundle 收敛,不得拆成多个分析 agent 往返消耗 token。 +4.1 若设计决策或前置依赖仍未在 spec 中固定,必须先指出并阻塞,不得在代码里私自补全关键业务决定。 +5. 遇到明显且同任务强相关的编译错误、测试错误、契约错配或资源释放缺陷,应在同一轮内顺手修掉,不要把显然可以内部解决的问题抛回 PM。 +6. 代码改动默认先跑与改动直接相关的最小验证集,再补跑项目全量构建;如果全量构建超过合理时长或出现挂起迹象,必须先返回已完成的最小验证结果、挂起位置和剩余缺口,不得无限等待。 +7. 未拿到真实执行结果前,禁止在 `summary` 中写"验证通过"或"建议进入测试";不得用计划中的命令替代已经执行的命令。 +8. 若当前任务只是既有任务的小幅 refinement,默认继续在当前任务内推进;若实现发现目标已经实质变更、与原范围重叠过低或原任务可以独立收口,必须提醒 PM 考虑拆为新任务。 + +# 防挂起与超时硬规则 +1. 所有终端命令必须遵守以下超时限制,超时后必须立即终止命令并输出已完成步骤与最后可见输出: + - 单个测试类/文件运行:最长 120 秒。 + - 模块级测试:最长 300 秒。 + - 全量构建:最长 600 秒。 + - 任何其他终端命令:最长 180 秒。 +2. 执行构建/测试命令时,必须遵守渐进式验证策略,按以下顺序递进,上一步完成后才允许进入下一步: + - 第一步:编译确认。 + - 第二步:运行与改动最直接相关的单个测试类/文件。 + - 第三步:运行改动模块的全部测试。 + - 第四步:仅在前三步通过且时间允许时,才运行全量构建。 + - 若任一步骤超时或挂起,立即停止后续步骤,返回已完成验证和阻塞点。 +3. 禁止在单次会话中读取超过 15 个文件或累计超过 3000 行代码后仍未开始产出任何代码改动;若读取量接近限制,必须停止扩大读取范围并基于已有信息开始实现。 +4. 若测试依赖外部服务(消息队列、对象存储、数据库等),必须确认测试有超时保护;若外部服务在 15 秒内未建立连接,必须判定为环境不可达并返回 blocked,不得无限等待。 +5. 禁止在终端中运行交互式命令或需要用户手动输入确认的命令。 +6. 若某一步骤已成功完成但后续步骤挂起,必须保留已成功的结果并输出部分完成状态(`status=ready` 配合 artifacts 标注未完成项),不得因后续挂起而丢弃全部进度。 + +# 统一 JSON 结果模板 +1. `task_id`: 任务唯一标识。 +2. `current_stage`: 固定填写 `implementation`。 +3. `status`: `ready`、`blocked`、`need_clarification` 三选一。 +4. `summary`: 必须使用固定句式:`实现结论:已完成{改动范围};验证{tests_run 摘要};默认值{default_values_used 摘要};建议进入{recommended_next_stage}。` +5. `artifacts`: 至少包含以下子字段: + - `design_decisions` + - `changed_files` + - `tests_run` + - `self_review` + - `default_values_used` +5.1 `artifacts` 在条件允许时还应补充 `traceability_links`、`drift_findings`。 +5.2 若启用了架构拆解,`artifacts` 还应包含 `sub_tasks` 数组: + - 每个子任务包含 `sub_task_id`、`description`、`files_involved`、`dependencies`、`parallel_ready`、`assigned_to`(`tech-architect` 或 `developer`)。 +6. `risks`: 剩余风险数组。 +7. `recommended_next_stage`: 只能填写 `verification`、`implementation`、`closed` 之一。 +8. `needs_pm_attention`: `true|false`。 + diff --git a/packages/platform-gitlab/skills/review/verification-matrix/SKILL.md b/packages/platform-gitlab/skills/review/verification-matrix/SKILL.md new file mode 100644 index 00000000..2d3d0dbd --- /dev/null +++ b/packages/platform-gitlab/skills/review/verification-matrix/SKILL.md @@ -0,0 +1,16 @@ +--- +name: platform.gitlab.verification-matrix +description: Use to plan minimal QA checks for GitLab review findings. +--- + +# Verification Matrix + +For each meaningful behavior change, identify the smallest useful verification set: + +- existing automated test that should cover it. +- missing test worth adding. +- manual reproduction path when automation is unavailable. +- residual risk if verification could not run. + +Do not request broad test suites unless the changed files touch shared runtime, platform registration, config loading, or security-sensitive code paths. + diff --git a/packages/platform-gitlab/src/index.ts b/packages/platform-gitlab/src/index.ts index d354e17f..265618b3 100644 --- a/packages/platform-gitlab/src/index.ts +++ b/packages/platform-gitlab/src/index.ts @@ -12,4 +12,5 @@ export { normalizeGitLabPagePayload, } from './runtime' export type { GitLabPlatformAdapter } from './runtime' +export * from './review' export type * from './types' diff --git a/packages/platform-gitlab/src/review/api-client.ts b/packages/platform-gitlab/src/review/api-client.ts new file mode 100644 index 00000000..daff9a52 --- /dev/null +++ b/packages/platform-gitlab/src/review/api-client.ts @@ -0,0 +1,66 @@ +import type { GitLabRawChangesResponse } from './types' + +export type GitLabApiClientOptions = { + baseUrl: string + token: string + fetch?: typeof fetch +} + +export type GitLabCreateNoteInput = { + projectId: string | number + resource: 'merge_requests' | 'repository/commits' + resourceId: string | number + body: string +} + +export type GitLabCreateDiscussionInput = GitLabCreateNoteInput & { + position?: Record +} + +export class GitLabApiClient { + private readonly baseUrl: string + private readonly token: string + private readonly fetchImpl: typeof fetch + + constructor(options: GitLabApiClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, '') + this.token = options.token + this.fetchImpl = options.fetch ?? fetch + } + + async getMergeRequestChanges(projectId: string | number, mrIid: string | number): Promise { + return await this.request( + `/api/v4/projects/${encodeURIComponent(String(projectId))}/merge_requests/${encodeURIComponent(String(mrIid))}/changes`, + ) + } + + async createNote(input: GitLabCreateNoteInput): Promise { + return await this.request(`/api/v4/projects/${encodeURIComponent(String(input.projectId))}/${input.resource}/${encodeURIComponent(String(input.resourceId))}/notes`, { + method: 'POST', + body: new URLSearchParams({ body: input.body }), + }) + } + + async createDiscussion(input: GitLabCreateDiscussionInput): Promise { + const body = new URLSearchParams({ body: input.body }) + if (input.position) body.set('position', JSON.stringify(input.position)) + return await this.request(`/api/v4/projects/${encodeURIComponent(String(input.projectId))}/${input.resource}/${encodeURIComponent(String(input.resourceId))}/discussions`, { + method: 'POST', + body, + }) + } + + private async request(path: string, init: RequestInit = {}): Promise { + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + ...init, + headers: { + 'PRIVATE-TOKEN': this.token, + ...(init.headers ?? {}), + }, + }) + if (!response.ok) { + throw new Error(`GitLab API request failed: ${response.status} ${response.statusText}`) + } + return await response.json() as T + } +} diff --git a/packages/platform-gitlab/src/review/comment-renderer.ts b/packages/platform-gitlab/src/review/comment-renderer.ts new file mode 100644 index 00000000..19b5f8c9 --- /dev/null +++ b/packages/platform-gitlab/src/review/comment-renderer.ts @@ -0,0 +1,49 @@ +import type { AggregatedReviewFinding, GitLabDiffManifest } from './types' + +export function renderBlockedDiffComment(reason: string) { + return [ + '⚠️ GitLab review blocked', + '', + reason, + '', + 'MR diff is too large or was truncated by GitLab. Please split the MR or request a manual review.', + ].join('\n') +} + +export function renderReviewSummaryComment(input: { + title?: string + summary: string + findings: AggregatedReviewFinding[] + manifest?: GitLabDiffManifest + warnings?: string[] +}) { + const lines = [ + `## ${input.title ?? 'Nine1bot GitLab Review'}`, + '', + input.summary, + '', + `Findings: ${input.findings.length}`, + ] + + if (input.manifest) { + lines.push( + `Diff files: ${input.manifest.stats.includedFileCount}/${input.manifest.stats.fileCount}`, + `Skipped files: ${input.manifest.stats.skippedFileCount}`, + ) + } + + if (input.warnings?.length) { + lines.push('', '### Warnings', ...input.warnings.map((warning) => `- ${warning}`)) + } + + if (input.findings.length) { + lines.push('', '### Findings') + for (const finding of input.findings) { + lines.push( + `- **${finding.severity.toUpperCase()}** ${finding.file ? `\`${finding.file}${finding.newLine || finding.oldLine ? `:${finding.newLine ?? finding.oldLine}` : ''}\` ` : ''}${finding.title}`, + ) + } + } + + return lines.join('\n') +} diff --git a/packages/platform-gitlab/src/review/context-builder.ts b/packages/platform-gitlab/src/review/context-builder.ts new file mode 100644 index 00000000..80d3d192 --- /dev/null +++ b/packages/platform-gitlab/src/review/context-builder.ts @@ -0,0 +1,68 @@ +import { buildGitLabDiffManifest } from './diff-builder' +import { buildGitLabReviewIdempotencyKey } from './idempotency' +import type { GitLabRawChangesResponse, GitLabReviewTrigger } from './types' + +export type GitLabReviewContext = { + trigger: GitLabReviewTrigger + idempotencyKey: string + diff: ReturnType + contextBlocks: Array<{ + id: string + source: string + content: string + }> +} + +export function buildGitLabReviewContext(input: { + trigger: GitLabReviewTrigger + changes: GitLabRawChangesResponse + maxDiffBytes?: number + maxFiles?: number +}): GitLabReviewContext { + const diff = buildGitLabDiffManifest(input.changes, { + maxDiffBytes: input.maxDiffBytes, + maxFiles: input.maxFiles, + }) + return { + trigger: input.trigger, + idempotencyKey: buildGitLabReviewIdempotencyKey(input.trigger), + diff, + contextBlocks: [ + { + id: 'gitlab-review-trigger', + source: 'platform.gitlab.review.trigger', + content: renderTrigger(input.trigger), + }, + { + id: 'gitlab-review-diff-manifest', + source: 'platform.gitlab.review.diff', + content: renderDiffManifest(diff), + }, + ], + } +} + +function renderTrigger(trigger: GitLabReviewTrigger) { + return [ + 'GitLab review trigger', + `Host: ${trigger.host}`, + `Project: ${trigger.projectPath ?? trigger.projectId}`, + `Object: ${trigger.objectType}`, + trigger.objectIid ? `IID: ${trigger.objectIid}` : undefined, + trigger.commitSha ? `Commit: ${trigger.commitSha}` : undefined, + trigger.headSha ? `Head SHA: ${trigger.headSha}` : undefined, + trigger.noteId ? `Note: ${trigger.noteId}` : undefined, + `Mode: ${trigger.mode}`, + ].filter(Boolean).join('\n') +} + +function renderDiffManifest(diff: GitLabReviewContext['diff']) { + if (diff.blocked) return `Blocked: ${diff.blockReason ?? 'diff blocked'}` + return [ + `Files included: ${diff.stats.includedFileCount}/${diff.stats.fileCount}`, + `Skipped files: ${diff.stats.skippedFileCount}`, + `Included bytes: ${diff.stats.includedBytes}`, + '', + ...diff.files.map((file) => `- ${file.newPath}${file.renamed ? ` (renamed from ${file.oldPath})` : ''}`), + ].join('\n') +} diff --git a/packages/platform-gitlab/src/review/diff-builder.ts b/packages/platform-gitlab/src/review/diff-builder.ts new file mode 100644 index 00000000..28901e4c --- /dev/null +++ b/packages/platform-gitlab/src/review/diff-builder.ts @@ -0,0 +1,126 @@ +import type { GitLabDiffManifest, GitLabRawChange, GitLabRawChangesResponse, GitLabSkippedFile } from './types' + +const BLACKLISTED_PATH_PATTERNS = [ + /(^|\/)(package-lock|npm-shrinkwrap)\.json$/i, + /(^|\/)(yarn|pnpm-lock|bun)\.lock$/i, + /(^|\/)(dist|build|coverage|\.next|\.nuxt|vendor)\//i, + /\.min\.(js|css)$/i, + /\.(map|svg|png|jpe?g|gif|webp|avif|ico|pdf|zip|tar|gz|mp4|mov|mp3|wav|woff2?|ttf|otf)$/i, + /(^|\/)generated\//i, +] + +export type BuildGitLabDiffManifestOptions = { + maxDiffBytes?: number + maxFiles?: number + blockOnOverflow?: boolean +} + +export function buildGitLabDiffManifest( + response: GitLabRawChangesResponse, + options: BuildGitLabDiffManifestOptions = {}, +): GitLabDiffManifest { + const maxDiffBytes = options.maxDiffBytes ?? 240_000 + const maxFiles = options.maxFiles ?? 80 + const changes = response.changes ?? [] + const truncated = Boolean(response.overflow || changes.some(isOverflowChange)) + + if ((options.blockOnOverflow ?? true) && truncated) { + return blockedManifest(response, changes, 'MR diff is too large or was truncated by GitLab.') + } + + const files = [] + const skipped: GitLabSkippedFile[] = [] + let includedBytes = 0 + + for (const change of changes) { + const path = displayPath(change) + if (change.generated_file) { + skipped.push({ path, reason: 'generated' }) + continue + } + if (isBlacklistedReviewPath(path)) { + skipped.push({ path, reason: 'blacklisted' }) + continue + } + if (change.too_large || change.collapsed) { + skipped.push({ path, reason: 'too-large' }) + continue + } + if (!change.diff?.trim()) { + skipped.push({ path, reason: 'empty-diff' }) + continue + } + const nextBytes = byteLength(change.diff) + if (files.length >= maxFiles || includedBytes + nextBytes > maxDiffBytes) { + skipped.push({ path, reason: 'budget-exceeded' }) + continue + } + includedBytes += nextBytes + files.push({ + oldPath: change.old_path, + newPath: change.new_path, + diff: change.diff, + added: Boolean(change.new_file), + renamed: Boolean(change.renamed_file), + deleted: Boolean(change.deleted_file), + generated: Boolean(change.generated_file), + }) + } + + return { + files, + skipped, + blocked: false, + diffRefs: normalizeDiffRefs(response), + stats: { + fileCount: changes.length, + includedFileCount: files.length, + skippedFileCount: skipped.length, + includedBytes, + truncated, + }, + } +} + +export function isBlacklistedReviewPath(path: string) { + return BLACKLISTED_PATH_PATTERNS.some((pattern) => pattern.test(path)) +} + +function blockedManifest(response: GitLabRawChangesResponse, changes: GitLabRawChange[], blockReason: string): GitLabDiffManifest { + return { + files: [], + skipped: changes.map((change) => ({ path: displayPath(change), reason: isBlacklistedReviewPath(displayPath(change)) ? 'blacklisted' : 'too-large' })), + blocked: true, + blockReason, + diffRefs: normalizeDiffRefs(response), + stats: { + fileCount: changes.length, + includedFileCount: 0, + skippedFileCount: changes.length, + includedBytes: 0, + truncated: true, + }, + } +} + +function isOverflowChange(change: GitLabRawChange) { + return Boolean(change.overflow || change.too_large) +} + +function displayPath(change: GitLabRawChange) { + return change.new_path || change.old_path +} + +function byteLength(input: string) { + return new TextEncoder().encode(input).length +} + +function normalizeDiffRefs(response: GitLabRawChangesResponse) { + return response.diff_refs + ? { + baseSha: response.diff_refs.base_sha, + startSha: response.diff_refs.start_sha, + headSha: response.diff_refs.head_sha, + } + : undefined +} diff --git a/packages/platform-gitlab/src/review/event-parser.ts b/packages/platform-gitlab/src/review/event-parser.ts new file mode 100644 index 00000000..45a875f3 --- /dev/null +++ b/packages/platform-gitlab/src/review/event-parser.ts @@ -0,0 +1,126 @@ +import type { GitLabReviewSettings } from './settings' +import type { GitLabReviewTrigger } from './types' + +export type GitLabParsedEvent = + | { ok: true; trigger: GitLabReviewTrigger } + | { ok: false; reason: string } + +export function parseGitLabWebhookEvent(payload: unknown, settings: GitLabReviewSettings): GitLabParsedEvent { + if (!settings.enabled) return { ok: false, reason: 'gitlab-review-disabled' } + if (!isRecord(payload)) return { ok: false, reason: 'invalid-payload' } + + const objectKind = stringValue(payload.object_kind) + if (objectKind === 'merge_request') return parseMergeRequestWebhook(payload, settings) + if (objectKind === 'note') return parseNoteWebhook(payload, settings) + return { ok: false, reason: `unsupported-event:${objectKind ?? 'unknown'}` } +} + +function parseMergeRequestWebhook(payload: Record, settings: GitLabReviewSettings): GitLabParsedEvent { + if (!settings.webhookAutoReview) return { ok: false, reason: 'webhook-auto-review-disabled' } + const project = recordValue(payload.project) + const attrs = recordValue(payload.object_attributes) + const projectId = idValue(project?.id ?? attrs?.target_project_id) + const mrIid = idValue(attrs?.iid) + const host = hostFromUrl(stringValue(project?.web_url) ?? stringValue(project?.git_http_url) ?? stringValue(project?.homepage)) + const headSha = stringValue(attrs?.last_commit && recordValue(attrs.last_commit)?.id) ?? stringValue(attrs?.last_commit_id) ?? stringValue(attrs?.sha) + if (!projectId || !mrIid || !host || !headSha) return { ok: false, reason: 'missing-merge-request-identity' } + if (!isAllowed(settings, host, projectId)) return { ok: false, reason: 'project-not-allowed' } + + return { + ok: true, + trigger: { + host, + projectId, + projectPath: stringValue(project?.path_with_namespace), + objectType: 'mr', + objectIid: mrIid, + headSha, + eventName: 'merge_request', + mode: 'webhook', + }, + } +} + +function parseNoteWebhook(payload: Record, settings: GitLabReviewSettings): GitLabParsedEvent { + if (!settings.manualMentionTrigger) return { ok: false, reason: 'manual-trigger-disabled' } + const project = recordValue(payload.project) + const note = recordValue(payload.object_attributes) + const mergeRequest = recordValue(payload.merge_request) + const commit = recordValue(payload.commit) + const noteText = stringValue(note?.note) + if (!noteText || !noteText.includes(settings.botMention)) return { ok: false, reason: 'mention-not-found' } + + const projectId = idValue(project?.id ?? note?.project_id) + const host = hostFromUrl(stringValue(project?.web_url) ?? stringValue(project?.git_http_url) ?? stringValue(project?.homepage)) + if (!projectId || !host) return { ok: false, reason: 'missing-project-identity' } + if (!isAllowed(settings, host, projectId)) return { ok: false, reason: 'project-not-allowed' } + + if (mergeRequest) { + const mrIid = idValue(mergeRequest.iid) + const headSha = stringValue(recordValue(mergeRequest.last_commit)?.id) ?? stringValue(mergeRequest.last_commit_id) ?? stringValue(mergeRequest.sha) + if (!mrIid || !headSha) return { ok: false, reason: 'missing-merge-request-note-identity' } + return { + ok: true, + trigger: { + host, + projectId, + projectPath: stringValue(project?.path_with_namespace), + objectType: 'mr', + objectIid: mrIid, + headSha, + noteId: idValue(note?.id), + eventName: 'note', + mode: 'mention', + }, + } + } + + const commitSha = stringValue(commit?.id) ?? stringValue(note?.commit_id) + if (!commitSha) return { ok: false, reason: 'missing-commit-note-identity' } + return { + ok: true, + trigger: { + host, + projectId, + projectPath: stringValue(project?.path_with_namespace), + objectType: 'commit', + commitSha, + noteId: idValue(note?.id), + eventName: 'note', + mode: 'mention', + }, + } +} + +function isAllowed(settings: GitLabReviewSettings, host: string, projectId: string | number) { + const hostAllowed = settings.allowedHosts.length === 0 || settings.allowedHosts.includes(host) + const projectAllowed = settings.allowedProjectIds.length === 0 || settings.allowedProjectIds.map(String).includes(String(projectId)) + return hostAllowed && projectAllowed +} + +function hostFromUrl(input?: string) { + if (!input) return undefined + try { + return new URL(input).hostname + } catch { + return undefined + } +} + +function isRecord(input: unknown): input is Record { + return typeof input === 'object' && input !== null && !Array.isArray(input) +} + +function recordValue(input: unknown) { + return isRecord(input) ? input : undefined +} + +function stringValue(input: unknown) { + return typeof input === 'string' && input.trim() ? input.trim() : undefined +} + +function idValue(input: unknown): string | number | undefined { + if (typeof input === 'number' && Number.isFinite(input)) return input + if (typeof input === 'string' && input.trim()) return input.trim() + return undefined +} diff --git a/packages/platform-gitlab/src/review/finding-aggregator.ts b/packages/platform-gitlab/src/review/finding-aggregator.ts new file mode 100644 index 00000000..6a29d8a8 --- /dev/null +++ b/packages/platform-gitlab/src/review/finding-aggregator.ts @@ -0,0 +1,52 @@ +import type { AggregatedReviewFinding, ReviewFinding } from './types' + +const severityRank = { + info: 0, + minor: 1, + major: 2, + critical: 3, + blocker: 4, +} as const + +export function aggregateReviewFindings(findings: ReviewFinding[]): AggregatedReviewFinding[] { + const grouped = new Map() + + for (const finding of findings) { + const key = findingKey(finding) + const existing = grouped.get(key) + if (!existing) { + grouped.set(key, { + ...finding, + sources: finding.source ? [finding.source] : [], + duplicates: [], + }) + continue + } + + existing.duplicates.push(finding) + if (finding.source && !existing.sources.includes(finding.source)) { + existing.sources.push(finding.source) + } + if (severityRank[finding.severity] > severityRank[existing.severity]) { + existing.severity = finding.severity + } + existing.body = mergeBody(existing.body, finding.body) + } + + return [...grouped.values()] +} + +function findingKey(finding: ReviewFinding) { + return [ + finding.file ?? '', + finding.oldLine ?? '', + finding.newLine ?? '', + finding.category ?? '', + ].join(':') +} + +function mergeBody(left: string, right: string) { + if (!right || left.includes(right)) return left + if (!left) return right + return `${left}\n\n${right}` +} diff --git a/packages/platform-gitlab/src/review/idempotency.ts b/packages/platform-gitlab/src/review/idempotency.ts new file mode 100644 index 00000000..0800fabd --- /dev/null +++ b/packages/platform-gitlab/src/review/idempotency.ts @@ -0,0 +1,36 @@ +import type { GitLabReviewTrigger } from './types' + +export function buildGitLabReviewIdempotencyKey(trigger: GitLabReviewTrigger): string { + const base = [ + 'gitlab', + normalizePart(trigger.host), + normalizePart(trigger.projectId), + ] + + if (trigger.objectType === 'mr') { + const mrIid = requiredPart(trigger.objectIid, 'MR IID') + const headSha = requiredPart(trigger.headSha, 'MR head SHA') + base.push('mr', mrIid, 'head_sha', headSha) + } else { + const commitSha = requiredPart(trigger.commitSha ?? trigger.headSha, 'commit SHA') + base.push('commit', commitSha) + } + + if (trigger.noteId !== undefined && trigger.noteId !== null) { + base.push('note', normalizePart(trigger.noteId)) + } else { + base.push('auto', normalizePart(trigger.eventName ?? trigger.mode)) + } + + return base.join(':') +} + +function requiredPart(input: unknown, label: string) { + const normalized = normalizePart(input) + if (!normalized) throw new Error(`Cannot build GitLab review idempotency key without ${label}.`) + return normalized +} + +function normalizePart(input: unknown) { + return String(input ?? '').trim().replaceAll(':', '%3A') +} diff --git a/packages/platform-gitlab/src/review/index.ts b/packages/platform-gitlab/src/review/index.ts new file mode 100644 index 00000000..9b277345 --- /dev/null +++ b/packages/platform-gitlab/src/review/index.ts @@ -0,0 +1,13 @@ +export * from './api-client' +export * from './comment-renderer' +export * from './context-builder' +export * from './diff-builder' +export * from './event-parser' +export * from './finding-aggregator' +export * from './idempotency' +export * from './inline-position' +export * from './output-schema' +export * from './settings' +export * from './types' +export * from './webhook' +export * from './workflow' diff --git a/packages/platform-gitlab/src/review/inline-position.ts b/packages/platform-gitlab/src/review/inline-position.ts new file mode 100644 index 00000000..1dd4e7d8 --- /dev/null +++ b/packages/platform-gitlab/src/review/inline-position.ts @@ -0,0 +1,90 @@ +import type { GitLabChangedFile, GitLabDiffRefs, GitLabInlineValidation, ReviewFinding } from './types' + +export function validateGitLabInlinePosition( + finding: ReviewFinding, + files: GitLabChangedFile[], + diffRefs?: GitLabDiffRefs, +): GitLabInlineValidation { + if (!finding.file || (!finding.newLine && !finding.oldLine)) { + return fallback(finding, 'Finding does not include a file and diff line.') + } + + const file = files.find((candidate) => candidate.newPath === finding.file || candidate.oldPath === finding.file) + if (!file) return fallback(finding, 'Finding file is not part of the included diff.') + + const ranges = changedLineRanges(file.diff) + const newLine = finding.newLine + const oldLine = finding.oldLine + const validNewLine = newLine !== undefined && ranges.newLines.has(newLine) + const validOldLine = oldLine !== undefined && ranges.oldLines.has(oldLine) + + if (!validNewLine && !validOldLine) { + return fallback(finding, `Line ${newLine ?? oldLine} is not an added or removed line in the diff hunk.`) + } + + return { + ok: true, + position: { + position_type: 'text', + base_sha: diffRefs?.baseSha, + start_sha: diffRefs?.startSha, + head_sha: diffRefs?.headSha, + old_path: file.oldPath, + new_path: file.newPath, + old_line: validOldLine ? oldLine : undefined, + new_line: validNewLine ? newLine : undefined, + }, + } +} + +export function changedLineRanges(diff: string) { + const oldLines = new Set() + const newLines = new Set() + let oldLine = 0 + let newLine = 0 + + for (const line of diff.split('\n')) { + const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line) + if (hunk) { + oldLine = Number(hunk[1]) + newLine = Number(hunk[2]) + continue + } + if (!oldLine && !newLine) continue + if (line.startsWith('+') && !line.startsWith('+++')) { + newLines.add(newLine) + newLine += 1 + continue + } + if (line.startsWith('-') && !line.startsWith('---')) { + oldLines.add(oldLine) + oldLine += 1 + continue + } + if (!line.startsWith('\\')) { + oldLine += 1 + newLine += 1 + } + } + + return { oldLines, newLines } +} + +export function renderInlineFallbackFinding(finding: ReviewFinding, reason: string) { + return [ + `### ${finding.title}`, + '', + `Inline comment fallback: ${reason}`, + finding.file ? `File: \`${finding.file}\`${finding.newLine || finding.oldLine ? `:${finding.newLine ?? finding.oldLine}` : ''}` : undefined, + '', + finding.body, + ].filter(Boolean).join('\n') +} + +function fallback(finding: ReviewFinding, reason: string): GitLabInlineValidation { + return { + ok: false, + reason, + fallbackMarkdown: renderInlineFallbackFinding(finding, reason), + } +} diff --git a/packages/platform-gitlab/src/review/output-schema.ts b/packages/platform-gitlab/src/review/output-schema.ts new file mode 100644 index 00000000..4fe38896 --- /dev/null +++ b/packages/platform-gitlab/src/review/output-schema.ts @@ -0,0 +1,98 @@ +import type { ReviewFinding } from './types' + +export type ReviewStageResult = { + stage: string + status: 'ok' | 'blocked' | 'failed' + summary: string + findings: ReviewFinding[] + nextActions?: string[] +} + +export const reviewStageResultJsonSchema = { + type: 'object', + additionalProperties: false, + required: ['stage', 'status', 'summary', 'findings'], + properties: { + stage: { type: 'string' }, + status: { type: 'string', enum: ['ok', 'blocked', 'failed'] }, + summary: { type: 'string' }, + findings: { + type: 'array', + items: { + type: 'object', + additionalProperties: true, + required: ['title', 'body', 'severity'], + properties: { + id: { type: 'string' }, + title: { type: 'string' }, + body: { type: 'string' }, + severity: { type: 'string', enum: ['info', 'minor', 'major', 'critical', 'blocker'] }, + category: { type: 'string' }, + file: { type: 'string' }, + oldLine: { type: 'number' }, + newLine: { type: 'number' }, + source: { type: 'string' }, + }, + }, + }, + nextActions: { + type: 'array', + items: { type: 'string' }, + }, + }, +} satisfies Record + +export function parseReviewStageResult(input: unknown): ReviewStageResult { + if (!isRecord(input)) throw new Error('Review stage result must be an object.') + const findingsInput = input.findings + if (!Array.isArray(findingsInput)) throw new Error('Review stage result findings must be an array.') + return { + stage: stringField(input.stage, 'stage'), + status: statusField(input.status), + summary: stringField(input.summary, 'summary'), + findings: findingsInput.map(parseFinding), + nextActions: Array.isArray(input.nextActions) ? input.nextActions.filter((item): item is string => typeof item === 'string') : undefined, + } +} + +function parseFinding(input: unknown): ReviewFinding { + if (!isRecord(input)) throw new Error('Review finding must be an object.') + return { + id: optionalString(input.id), + title: stringField(input.title, 'finding.title'), + body: stringField(input.body, 'finding.body'), + severity: severityField(input.severity), + category: optionalString(input.category), + file: optionalString(input.file), + oldLine: optionalNumber(input.oldLine), + newLine: optionalNumber(input.newLine), + source: optionalString(input.source), + } +} + +function isRecord(input: unknown): input is Record { + return typeof input === 'object' && input !== null && !Array.isArray(input) +} + +function stringField(input: unknown, field: string) { + if (typeof input !== 'string') throw new Error(`${field} must be a string.`) + return input +} + +function optionalString(input: unknown) { + return typeof input === 'string' ? input : undefined +} + +function optionalNumber(input: unknown) { + return typeof input === 'number' && Number.isFinite(input) ? input : undefined +} + +function statusField(input: unknown): ReviewStageResult['status'] { + if (input === 'ok' || input === 'blocked' || input === 'failed') return input + throw new Error('status must be ok, blocked, or failed.') +} + +function severityField(input: unknown): ReviewFinding['severity'] { + if (input === 'info' || input === 'minor' || input === 'major' || input === 'critical' || input === 'blocker') return input + throw new Error('finding.severity is invalid.') +} diff --git a/packages/platform-gitlab/src/review/settings.ts b/packages/platform-gitlab/src/review/settings.ts new file mode 100644 index 00000000..3374ff7f --- /dev/null +++ b/packages/platform-gitlab/src/review/settings.ts @@ -0,0 +1,111 @@ +export type GitLabReviewSettings = { + enabled: boolean + baseUrl?: string + botMention: string + allowedHosts: string[] + allowedProjectIds: Array + webhookSecretRef?: GitLabReviewSecretRef + tokenSecretRef?: GitLabReviewSecretRef + manualMentionTrigger: boolean + webhookAutoReview: boolean + inlineComments: boolean + dryRun: boolean + maxDiffBytes: number + maxFiles: number + executionMode: 'dry-run' | 'runtime' +} + +export type GitLabReviewSecretRef = string | { + provider: 'nine1bot-local' | 'env' | 'external' + key: string +} + +export const defaultGitLabReviewSettings: GitLabReviewSettings = { + enabled: false, + baseUrl: undefined, + botMention: '@Nine1bot', + allowedHosts: [], + allowedProjectIds: [], + manualMentionTrigger: true, + webhookAutoReview: false, + inlineComments: true, + dryRun: true, + maxDiffBytes: 240_000, + maxFiles: 80, + executionMode: 'dry-run', +} + +export function normalizeGitLabReviewSettings(input: unknown): GitLabReviewSettings { + const record = isRecord(input) ? input : {} + return { + ...defaultGitLabReviewSettings, + enabled: booleanValue(setting(record, 'review.enabled', 'enabled'), defaultGitLabReviewSettings.enabled), + baseUrl: optionalString(setting(record, 'review.baseUrl', 'baseUrl')), + botMention: stringValue(setting(record, 'review.botMention', 'botMention'), defaultGitLabReviewSettings.botMention), + allowedHosts: stringList(setting(record, 'allowedHosts')), + allowedProjectIds: idList(setting(record, 'review.allowedProjectIds', 'allowedProjectIds')), + webhookSecretRef: optionalSecretRef(setting(record, 'review.webhookSecretRef', 'webhookSecretRef')), + tokenSecretRef: optionalSecretRef(setting(record, 'review.tokenSecretRef', 'tokenSecretRef')), + manualMentionTrigger: booleanValue(setting(record, 'review.manualMentionTrigger', 'manualMentionTrigger'), defaultGitLabReviewSettings.manualMentionTrigger), + webhookAutoReview: booleanValue(setting(record, 'review.webhookAutoReview', 'webhookAutoReview'), defaultGitLabReviewSettings.webhookAutoReview), + inlineComments: booleanValue(setting(record, 'review.inlineComments', 'inlineComments'), defaultGitLabReviewSettings.inlineComments), + dryRun: booleanValue(setting(record, 'review.dryRun', 'dryRun'), defaultGitLabReviewSettings.dryRun), + maxDiffBytes: positiveNumber(setting(record, 'review.maxDiffBytes', 'maxDiffBytes'), defaultGitLabReviewSettings.maxDiffBytes), + maxFiles: positiveNumber(setting(record, 'review.maxFiles', 'maxFiles'), defaultGitLabReviewSettings.maxFiles), + executionMode: setting(record, 'review.executionMode', 'executionMode') === 'runtime' ? 'runtime' : 'dry-run', + } +} + +function isRecord(input: unknown): input is Record { + return typeof input === 'object' && input !== null && !Array.isArray(input) +} + +function booleanValue(input: unknown, fallback: boolean) { + return typeof input === 'boolean' ? input : fallback +} + +function stringValue(input: unknown, fallback: string) { + return typeof input === 'string' && input.trim() ? input.trim() : fallback +} + +function optionalString(input: unknown) { + return typeof input === 'string' && input.trim() ? input.trim() : undefined +} + +function optionalSecretRef(input: unknown): GitLabReviewSecretRef | undefined { + if (typeof input === 'string' && input.trim()) return input.trim() + if (!isRecord(input)) return undefined + if ( + (input.provider === 'nine1bot-local' || input.provider === 'env' || input.provider === 'external') && + typeof input.key === 'string' + ) { + return { + provider: input.provider, + key: input.key, + } + } + return undefined +} + +function stringList(input: unknown) { + return Array.isArray(input) + ? input.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).map((item) => item.trim()) + : [] +} + +function idList(input: unknown) { + return Array.isArray(input) + ? input.filter((item): item is string | number => typeof item === 'string' || typeof item === 'number') + : [] +} + +function positiveNumber(input: unknown, fallback: number) { + return typeof input === 'number' && Number.isFinite(input) && input > 0 ? input : fallback +} + +function setting(record: Record, ...keys: string[]) { + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(record, key)) return record[key] + } + return undefined +} diff --git a/packages/platform-gitlab/src/review/types.ts b/packages/platform-gitlab/src/review/types.ts new file mode 100644 index 00000000..ad0e15de --- /dev/null +++ b/packages/platform-gitlab/src/review/types.ts @@ -0,0 +1,132 @@ +export type GitLabReviewObjectType = 'mr' | 'commit' + +export type GitLabReviewTriggerMode = 'mention' | 'webhook' + +export type GitLabReviewTrigger = { + host: string + projectId: string | number + projectPath?: string + objectType: GitLabReviewObjectType + objectIid?: string | number + commitSha?: string + headSha?: string + noteId?: string | number + eventName?: string + mode: GitLabReviewTriggerMode +} + +export type GitLabDiffRefs = { + baseSha?: string + startSha?: string + headSha?: string +} + +export type GitLabRawChange = { + old_path: string + new_path: string + diff?: string + new_file?: boolean + renamed_file?: boolean + deleted_file?: boolean + generated_file?: boolean + collapsed?: boolean + too_large?: boolean + overflow?: boolean +} + +export type GitLabRawChangesResponse = { + changes?: GitLabRawChange[] + overflow?: boolean + diff_refs?: { + base_sha?: string + start_sha?: string + head_sha?: string + } +} + +export type GitLabChangedFile = { + oldPath: string + newPath: string + diff: string + added: boolean + renamed: boolean + deleted: boolean + generated: boolean +} + +export type GitLabDiffManifest = { + files: GitLabChangedFile[] + skipped: GitLabSkippedFile[] + blocked: boolean + blockReason?: string + diffRefs?: GitLabDiffRefs + stats: { + fileCount: number + includedFileCount: number + skippedFileCount: number + includedBytes: number + truncated: boolean + } +} + +export type GitLabSkippedFile = { + path: string + reason: 'blacklisted' | 'empty-diff' | 'too-large' | 'generated' | 'budget-exceeded' +} + +export type ReviewSeverity = 'info' | 'minor' | 'major' | 'critical' | 'blocker' + +export type ReviewFinding = { + id?: string + title: string + body: string + severity: ReviewSeverity + category?: string + file?: string + oldLine?: number + newLine?: number + source?: string +} + +export type AggregatedReviewFinding = ReviewFinding & { + sources: string[] + duplicates: ReviewFinding[] +} + +export type GitLabInlinePosition = { + position_type: 'text' + base_sha?: string + start_sha?: string + head_sha?: string + old_path: string + new_path: string + old_line?: number + new_line?: number +} + +export type GitLabInlineValidation = + | { + ok: true + position: GitLabInlinePosition + } + | { + ok: false + reason: string + fallbackMarkdown: string + } + +export type SubagentFailureMode = 'abort-run' | 'ignore' | 'fallback' + +export type SubagentTaskSpec = { + id: string + kind: 'custom-subagent' + role: string + prompt?: string + promptRef?: string + skills?: string[] + contextRefs?: string[] + allowedTools?: string[] + timeoutMs: number + failureMode: SubagentFailureMode + outputSchema?: Record +} diff --git a/packages/platform-gitlab/src/review/webhook.ts b/packages/platform-gitlab/src/review/webhook.ts new file mode 100644 index 00000000..d6a591ff --- /dev/null +++ b/packages/platform-gitlab/src/review/webhook.ts @@ -0,0 +1,26 @@ +export type GitLabWebhookValidation = { + ok: boolean + reason?: string +} + +export function validateGitLabWebhookToken(input: { + expectedSecret?: string + receivedToken?: string | null +}): GitLabWebhookValidation { + if (!input.expectedSecret) return { ok: false, reason: 'missing-webhook-secret' } + if (!input.receivedToken) return { ok: false, reason: 'missing-x-gitlab-token' } + return timingSafeEqualString(input.expectedSecret, input.receivedToken) + ? { ok: true } + : { ok: false, reason: 'invalid-x-gitlab-token' } +} + +function timingSafeEqualString(left: string, right: string) { + const leftBytes = new TextEncoder().encode(left) + const rightBytes = new TextEncoder().encode(right) + let diff = leftBytes.length ^ rightBytes.length + const max = Math.max(leftBytes.length, rightBytes.length) + for (let index = 0; index < max; index++) { + diff |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0) + } + return diff === 0 +} diff --git a/packages/platform-gitlab/src/review/workflow.ts b/packages/platform-gitlab/src/review/workflow.ts new file mode 100644 index 00000000..90922723 --- /dev/null +++ b/packages/platform-gitlab/src/review/workflow.ts @@ -0,0 +1,40 @@ +import { reviewStageResultJsonSchema } from './output-schema' +import type { SubagentTaskSpec } from './types' + +export const gitLabReviewSkillIds = [ + 'platform.gitlab.gitlab-mr-review-workflow', + 'platform.gitlab.spec-gate-review', + 'platform.gitlab.pm-risk-routing', + 'platform.gitlab.review-finding-schema', + 'platform.gitlab.verification-matrix', + 'platform.gitlab.security-review-policy', + 'platform.gitlab.gitlab-comment-rendering', +] as const + +export function buildInitialGitLabReviewSubagentTasks(): SubagentTaskSpec[] { + return [ + subagent('discovery-spec', 'spec-writer', 'platform.gitlab.subagent-prompts.spec-writer', 'abort-run'), + subagent('technical-architecture', 'tech-architect', 'platform.gitlab.subagent-prompts.tech-architect', 'fallback'), + subagent('frontend-review', 'frontend-designer', 'platform.gitlab.subagent-prompts.frontend-designer', 'ignore'), + subagent('qa-verification', 'risk-qa', 'platform.gitlab.subagent-prompts.risk-qa', 'ignore'), + subagent('security-verification', 'security-agent', 'platform.gitlab.subagent-prompts.security-agent', 'ignore'), + ] +} + +function subagent( + id: string, + role: string, + promptRef: string, + failureMode: SubagentTaskSpec['failureMode'], +): SubagentTaskSpec { + return { + id, + kind: 'custom-subagent', + role, + promptRef, + skills: [...gitLabReviewSkillIds], + timeoutMs: 120_000, + failureMode, + outputSchema: reviewStageResultJsonSchema, + } +} diff --git a/packages/platform-gitlab/src/runtime.ts b/packages/platform-gitlab/src/runtime.ts index be8e996d..6ac6af7e 100644 --- a/packages/platform-gitlab/src/runtime.ts +++ b/packages/platform-gitlab/src/runtime.ts @@ -5,7 +5,17 @@ import { normalizeGitLabPagePayload, parseGitLabUrl, } from './shared' -import type { PlatformAdapterContribution, PlatformDescriptor, PlatformRuntimeAdapter } from '@nine1bot/platform-protocol' +import { fileURLToPath } from 'node:url' +import { normalizeGitLabReviewSettings } from './review/settings' +import type { + PlatformActionResult, + PlatformAdapterContext, + PlatformAdapterContribution, + PlatformDescriptor, + PlatformRuntimeAdapter, + PlatformRuntimeStatus, + PlatformValidationResult, +} from '@nine1bot/platform-protocol' import type { PageContextPayload, PlatformContextBlock, PlatformResourceContribution } from './types' export type GitLabPlatformAdapter = PlatformRuntimeAdapter & { @@ -54,6 +64,69 @@ export const gitlabPlatformDescriptor = { }, ], }, + { + id: 'codeReview', + title: 'Code review', + description: 'Optional GitLab MR and commit code review automation. Disabled until explicitly enabled.', + fields: [ + { + key: 'review.baseUrl', + type: 'string', + label: 'GitLab base URL', + description: 'Base URL for the GitLab instance, for example https://gitlab.com.', + }, + { + key: 'review.enabled', + type: 'boolean', + label: 'Enable GitLab code review', + description: 'Allow @Nine1bot comments or configured webhooks to start GitLab review runs.', + }, + { + key: 'review.botMention', + type: 'string', + label: 'Bot mention', + description: 'Mention text that triggers manual review from GitLab comments.', + }, + { + key: 'review.webhookAutoReview', + type: 'boolean', + label: 'Webhook auto review', + description: 'Automatically review configured merge request webhook events.', + }, + { + key: 'review.inlineComments', + type: 'boolean', + label: 'Inline comments', + description: 'Attempt GitLab inline discussions for validated changed lines.', + }, + { + key: 'review.dryRun', + type: 'boolean', + label: 'Dry run', + description: 'Build review context without writing comments back to GitLab.', + }, + { + key: 'review.allowedProjectIds', + type: 'string-list', + label: 'Allowed project ids', + description: 'GitLab project ids allowed to trigger review runs.', + }, + { + key: 'review.webhookSecretRef', + type: 'password', + label: 'Webhook secret', + description: 'Secret used to validate X-Gitlab-Token.', + secret: true, + }, + { + key: 'review.tokenSecretRef', + type: 'password', + label: 'GitLab API token', + description: 'GitLab account token used to read diffs and write review comments.', + secret: true, + }, + ], + }, ], }, detailPage: { @@ -77,7 +150,30 @@ export const gitlabPlatformContribution = { descriptor: gitlabPlatformDescriptor, runtime: { createAdapter: createGitLabPlatformAdapter, + sources: { + agents: [ + { + id: 'gitlab-review-agents', + directory: fileURLToPath(new URL('../agents', import.meta.url)), + namespace: 'platform.gitlab', + visibility: 'recommendable', + lifecycle: 'platform-enabled', + }, + ], + skills: [ + { + id: 'gitlab-review-skills', + directory: fileURLToPath(new URL('../skills', import.meta.url)), + namespace: 'platform.gitlab', + visibility: 'declared-only', + lifecycle: 'platform-enabled', + }, + ], + }, }, + getStatus: getGitLabPlatformStatus, + validateConfig: validateGitLabPlatformConfig, + handleAction: handleGitLabPlatformAction, } satisfies PlatformAdapterContribution export function createGitLabPlatformAdapter(): GitLabPlatformAdapter { @@ -100,11 +196,91 @@ export function createGitLabPlatformAdapter(): GitLabPlatformAdapter { } return emptyResources(['gitlab-context']) }, + recommendedAgent(input) { + return input.templateIds.includes('gitlab-mr') ? 'platform.gitlab.pm-coordinator' : input.fallback + }, } } export { gitLabTemplateIdsForPage, normalizeGitLabPagePayload, parseGitLabUrl } +async function getGitLabPlatformStatus(ctx: PlatformAdapterContext): Promise { + const settings = normalizeGitLabReviewSettings(ctx.settings) + const cards: PlatformRuntimeStatus['cards'] = [ + { id: 'context', label: 'Page context', value: 'enabled', tone: 'success' }, + { id: 'review', label: 'Code review', value: settings.enabled ? 'enabled' : 'disabled', tone: settings.enabled ? 'success' : 'neutral' }, + { id: 'mode', label: 'Review mode', value: settings.executionMode, tone: settings.dryRun ? 'warning' : 'neutral' }, + ] + + if (!settings.enabled) { + return { + status: 'available', + message: 'GitLab page context is available. Code review is disabled until enabled in settings.', + cards, + } + } + + if (!settings.tokenSecretRef) { + return { + status: 'auth-required', + message: 'GitLab code review is enabled but no API token is configured.', + cards, + } + } + + const tokenConfigured = typeof settings.tokenSecretRef === 'string' || await ctx.secrets.has(settings.tokenSecretRef) + if (!tokenConfigured) { + return { + status: 'auth-required', + message: 'GitLab code review token is missing or unavailable.', + cards, + } + } + + return { + status: settings.dryRun ? 'degraded' : 'available', + message: settings.dryRun + ? 'GitLab code review is configured in dry-run mode; no comments will be written.' + : 'GitLab code review is configured.', + cards, + } +} + +async function validateGitLabPlatformConfig(settingsInput: unknown): Promise { + const settings = normalizeGitLabReviewSettings(settingsInput) + const fieldErrors: Record = {} + + if (settings.enabled) { + if (!settings.tokenSecretRef) fieldErrors['review.tokenSecretRef'] = 'GitLab API token is required when code review is enabled.' + if (settings.baseUrl && !isHttpUrl(settings.baseUrl)) fieldErrors['review.baseUrl'] = 'GitLab base URL must be an http(s) URL.' + if (!settings.botMention.trim().startsWith('@')) fieldErrors['review.botMention'] = 'Bot mention must start with @.' + } + + return Object.keys(fieldErrors).length + ? { ok: false, message: 'Invalid GitLab code review settings.', fieldErrors } + : { ok: true } +} + +async function handleGitLabPlatformAction( + actionId: string, + _input: unknown, + ctx: PlatformAdapterContext, +): Promise { + if (actionId !== 'connection.test') { + return { status: 'failed', message: `Unsupported GitLab action: ${actionId}` } + } + + const status = await getGitLabPlatformStatus(ctx) + if (status.status === 'auth-required' || status.status === 'error') { + return { status: 'failed', message: status.message, updatedStatus: status } + } + return { + status: 'ok', + message: 'GitLab platform settings are structurally valid. API reachability will be checked when live GitLab client wiring is enabled.', + updatedStatus: status, + } +} + function buildGitLabContextBlocks(page: PageContextPayload, observedAt: number): PlatformContextBlock[] | undefined { const adapted = normalizeGitLabPagePayload(page) if (!adapted) return undefined @@ -253,6 +429,15 @@ function stringValue(input: unknown): string | undefined { return typeof input === 'string' && input.trim() ? input : undefined } +function isHttpUrl(input: string) { + try { + const url = new URL(input) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} + function textDigest(input: string) { let hash = 0x811c9dc5 for (let index = 0; index < input.length; index++) { diff --git a/packages/platform-gitlab/test/gitlab-platform.test.ts b/packages/platform-gitlab/test/gitlab-platform.test.ts index 48a13f86..58823964 100644 --- a/packages/platform-gitlab/test/gitlab-platform.test.ts +++ b/packages/platform-gitlab/test/gitlab-platform.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' import { buildGitLabPageContextPayload, createGitLabPlatformAdapter, + gitlabPlatformContribution, gitLabTemplateIdsForPage, parseGitLabUrl, } from '../src' @@ -83,6 +84,7 @@ describe('GitLab platform adapter package', () => { const page = { platform: 'gitlab', url: 'https://gitlab.com/nine1/nine1bot/-/issues/7', + pageType: 'gitlab-issue', title: 'Issue 7', } const adapter = createGitLabPlatformAdapter() @@ -95,6 +97,25 @@ describe('GitLab platform adapter package', () => { 'template.gitlab-issue', ]) expect(adapter.resourceContributions({ templateIds })?.builtinTools.enabledGroups).toContain('gitlab-context') + expect(adapter.recommendedAgent?.({ templateIds, fallback: 'build' })).toBe('build') + expect(adapter.recommendedAgent?.({ templateIds: ['gitlab-mr'], fallback: 'build' })).toBe('platform.gitlab.pm-coordinator') + }) + + test('declares platform-scoped runtime sources for GitLab review assets', () => { + expect(gitlabPlatformContribution.runtime?.sources).toMatchObject({ + agents: [{ + id: 'gitlab-review-agents', + namespace: 'platform.gitlab', + visibility: 'recommendable', + lifecycle: 'platform-enabled', + }], + skills: [{ + id: 'gitlab-review-skills', + namespace: 'platform.gitlab', + visibility: 'declared-only', + lifecycle: 'platform-enabled', + }], + }) }) test('builds stable runtime page context blocks', () => { diff --git a/packages/platform-gitlab/test/gitlab-review.test.ts b/packages/platform-gitlab/test/gitlab-review.test.ts new file mode 100644 index 00000000..39d3aaf6 --- /dev/null +++ b/packages/platform-gitlab/test/gitlab-review.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from 'bun:test' +import { + aggregateReviewFindings, + buildGitLabDiffManifest, + buildGitLabReviewContext, + buildGitLabReviewIdempotencyKey, + defaultGitLabReviewSettings, + parseGitLabWebhookEvent, + validateGitLabInlinePosition, + validateGitLabWebhookToken, + type GitLabRawChangesResponse, + type ReviewFinding, +} from '../src' + +describe('GitLab review foundation', () => { + test('builds MR idempotency keys from head SHA and note id', () => { + const base = { + host: 'gitlab.example.com', + projectId: 123, + objectType: 'mr' as const, + objectIid: 10, + mode: 'webhook' as const, + eventName: 'merge_request', + } + + expect(buildGitLabReviewIdempotencyKey({ ...base, headSha: 'aaa' })).toBe( + 'gitlab:gitlab.example.com:123:mr:10:head_sha:aaa:auto:merge_request', + ) + expect(buildGitLabReviewIdempotencyKey({ ...base, headSha: 'bbb', noteId: 55, mode: 'mention' })).toBe( + 'gitlab:gitlab.example.com:123:mr:10:head_sha:bbb:note:55', + ) + }) + + test('blocks GitLab overflow diffs', () => { + const manifest = buildGitLabDiffManifest({ + overflow: true, + changes: [{ old_path: 'src/large.ts', new_path: 'src/large.ts', diff: '', overflow: true }], + }) + + expect(manifest.blocked).toBe(true) + expect(manifest.stats.truncated).toBe(true) + expect(manifest.files).toEqual([]) + }) + + test('filters noisy files before review context is built', () => { + const manifest = buildGitLabDiffManifest({ + changes: [ + { old_path: 'src/app.ts', new_path: 'src/app.ts', diff: '@@ -1 +1 @@\n-a\n+b\n' }, + { old_path: 'package-lock.json', new_path: 'package-lock.json', diff: '@@ -1 +1 @@\n-a\n+b\n' }, + { old_path: 'public/logo.svg', new_path: 'public/logo.svg', diff: '@@ -1 +1 @@\n-a\n+b\n' }, + ], + }) + + expect(manifest.blocked).toBe(false) + expect(manifest.files.map((file) => file.newPath)).toEqual(['src/app.ts']) + expect(manifest.skipped.map((file) => file.path)).toEqual(['package-lock.json', 'public/logo.svg']) + }) + + test('validates inline positions against changed diff lines', () => { + const response: GitLabRawChangesResponse = { + diff_refs: { base_sha: 'base', start_sha: 'start', head_sha: 'head' }, + changes: [{ + old_path: 'src/app.ts', + new_path: 'src/app.ts', + diff: '@@ -10,3 +10,4 @@\n context\n-old\n+new\n+another\n', + }], + } + const manifest = buildGitLabDiffManifest(response) + + expect(validateGitLabInlinePosition({ + title: 'Changed line', + body: 'Valid line', + severity: 'major', + file: 'src/app.ts', + newLine: 11, + }, manifest.files, manifest.diffRefs)).toMatchObject({ ok: true }) + + expect(validateGitLabInlinePosition({ + title: 'Context line', + body: 'Invalid line', + severity: 'major', + file: 'src/app.ts', + newLine: 10, + }, manifest.files, manifest.diffRefs)).toMatchObject({ ok: false }) + }) + + test('groups deterministic finding duplicates before PM polishing', () => { + const findings: ReviewFinding[] = [ + { title: 'Auth gap', body: 'QA body', severity: 'major', category: 'auth', file: 'src/auth.ts', newLine: 20, source: 'qa' }, + { title: 'Auth gap', body: 'Security body', severity: 'critical', category: 'auth', file: 'src/auth.ts', newLine: 20, source: 'security' }, + ] + + expect(aggregateReviewFindings(findings)).toMatchObject([ + { + file: 'src/auth.ts', + newLine: 20, + severity: 'critical', + sources: ['qa', 'security'], + duplicates: [expect.objectContaining({ source: 'security' })], + }, + ]) + }) + + test('keeps GitLab code review disabled by default', () => { + expect(defaultGitLabReviewSettings.enabled).toBe(false) + expect(defaultGitLabReviewSettings.executionMode).toBe('dry-run') + }) + + test('validates GitLab webhook tokens without accepting missing secrets', () => { + expect(validateGitLabWebhookToken({ expectedSecret: 'secret', receivedToken: 'secret' })).toEqual({ ok: true }) + expect(validateGitLabWebhookToken({ expectedSecret: 'secret', receivedToken: 'wrong' })).toMatchObject({ ok: false }) + expect(validateGitLabWebhookToken({ receivedToken: 'secret' })).toMatchObject({ ok: false, reason: 'missing-webhook-secret' }) + }) + + test('parses mention note webhooks into review triggers', () => { + const result = parseGitLabWebhookEvent({ + object_kind: 'note', + project: { + id: 123, + path_with_namespace: 'nine1/nine1bot', + web_url: 'https://gitlab.example.com/nine1/nine1bot', + }, + object_attributes: { + id: 777, + note: '@Nine1bot review this', + }, + merge_request: { + iid: 10, + last_commit: { id: 'abc123' }, + }, + }, { + ...defaultGitLabReviewSettings, + enabled: true, + allowedHosts: ['gitlab.example.com'], + allowedProjectIds: [123], + }) + + expect(result).toMatchObject({ + ok: true, + trigger: { + objectType: 'mr', + objectIid: 10, + headSha: 'abc123', + noteId: 777, + mode: 'mention', + }, + }) + }) + + test('builds review context blocks from trigger and changes', () => { + const context = buildGitLabReviewContext({ + trigger: { + host: 'gitlab.example.com', + projectId: 123, + objectType: 'mr', + objectIid: 10, + headSha: 'abc123', + mode: 'webhook', + }, + changes: { + changes: [{ old_path: 'src/app.ts', new_path: 'src/app.ts', diff: '@@ -1 +1 @@\n-a\n+b\n' }], + }, + }) + + expect(context.idempotencyKey).toBe('gitlab:gitlab.example.com:123:mr:10:head_sha:abc123:auto:webhook') + expect(context.contextBlocks.map((block) => block.source)).toEqual([ + 'platform.gitlab.review.trigger', + 'platform.gitlab.review.diff', + ]) + }) +}) diff --git a/packages/platform-gitlab/tsconfig.json b/packages/platform-gitlab/tsconfig.json index 9c88fe05..dfcf89f8 100644 --- a/packages/platform-gitlab/tsconfig.json +++ b/packages/platform-gitlab/tsconfig.json @@ -8,8 +8,12 @@ "skipLibCheck": true, "declaration": false, "noEmit": true, + "baseUrl": ".", + "paths": { + "@nine1bot/platform-protocol": ["../platform-protocol/src/index.ts"] + }, "types": ["bun-types"] }, - "include": ["src/**/*.ts", "test/**/*.ts"], + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"], "exclude": ["node_modules", "dist"] } From ce7d02d7e2128b357382bc2fb954207ac96846ef Mon Sep 17 00:00:00 2001 From: legendPei <804141866@qq.com> Date: Sat, 2 May 2026 10:40:13 +0800 Subject: [PATCH 02/60] feat(gitlab): add review webhook entry --- .../opencode/src/server/routes/webhooks.ts | 38 +++++- .../src/review/gitlab-controller.test.ts | 102 +++++++++++++++ .../nine1bot/src/review/gitlab-controller.ts | 123 ++++++++++++++++++ 3 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 packages/nine1bot/src/review/gitlab-controller.test.ts create mode 100644 packages/nine1bot/src/review/gitlab-controller.ts diff --git a/opencode/packages/opencode/src/server/routes/webhooks.ts b/opencode/packages/opencode/src/server/routes/webhooks.ts index 167b54b3..7e3f661b 100644 --- a/opencode/packages/opencode/src/server/routes/webhooks.ts +++ b/opencode/packages/opencode/src/server/routes/webhooks.ts @@ -8,6 +8,9 @@ import z from "zod" import { lazy } from "../../util/lazy" import { errors } from "../error" import { runAutomatedControllerSession, type AutomatedControllerResponse } from "./automated-controller" +import { handleGitLabReviewWebhook } from "../../../../../../packages/nine1bot/src/review/gitlab-controller" +import { readPlatformManagerConfig } from "../../../../../../packages/nine1bot/src/platform/config-store" +import { FilePlatformSecretStore } from "../../../../../../packages/nine1bot/src/platform/secrets" const WEBHOOK_CLIENT_CAPABILITIES = { interactions: false, @@ -335,12 +338,37 @@ async function triggerWebhook(c: any) { }) } +async function triggerGitLabReviewWebhook(c: any) { + const contentType = c.req.header("content-type") || "" + if (!contentType.toLowerCase().includes("application/json")) { + return c.json({ accepted: false, error: "json_body_required" }, 400) + } + + let payload: unknown + try { + payload = await c.req.json() + } catch { + return c.json({ accepted: false, error: "invalid_json_body" }, 400) + } + + const result = await handleGitLabReviewWebhook({ + payload, + headers: Webhook.normalizeHeaders(c.req.raw.headers), + platforms: await readPlatformManagerConfig(), + secrets: new FilePlatformSecretStore(process.env.NINE1BOT_PLATFORM_SECRETS_PATH), + }) + + return c.json(result, result.accepted ? 202 : result.httpStatus as never) +} + export const WebhookPublicRoutes = lazy(() => - new Hono().post( - "/:sourceID/:secret", - validator("param", z.object({ sourceID: z.string(), secret: z.string() })), - triggerWebhook, - ), + new Hono() + .post("/gitlab", triggerGitLabReviewWebhook) + .post( + "/:sourceID/:secret", + validator("param", z.object({ sourceID: z.string(), secret: z.string() })), + triggerWebhook, + ), ) export const WebhookRoutes = lazy(() => diff --git a/packages/nine1bot/src/review/gitlab-controller.test.ts b/packages/nine1bot/src/review/gitlab-controller.test.ts new file mode 100644 index 00000000..38d31d08 --- /dev/null +++ b/packages/nine1bot/src/review/gitlab-controller.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from 'bun:test' +import { handleGitLabReviewWebhook } from './gitlab-controller' +import type { PlatformSecretAccess, PlatformSecretRef } from '@nine1bot/platform-protocol' + +const memorySecrets: PlatformSecretAccess = { + async get(ref: PlatformSecretRef) { + return ref.key === 'gitlab-webhook' ? 'secret' : undefined + }, + async set() {}, + async delete() {}, + async has(ref: PlatformSecretRef) { + return ref.key === 'gitlab-webhook' + }, +} + +const platforms = { + gitlab: { + enabled: true, + settings: { + 'review.enabled': true, + 'review.webhookSecretRef': { + provider: 'nine1bot-local', + key: 'gitlab-webhook', + }, + 'review.tokenSecretRef': { + provider: 'nine1bot-local', + key: 'gitlab-token', + }, + 'review.dryRun': true, + 'review.webhookAutoReview': true, + allowedHosts: ['gitlab.example.com'], + 'review.allowedProjectIds': ['123'], + }, + }, +} + +describe('GitLab review controller', () => { + test('rejects disabled GitLab review', async () => { + await expect(handleGitLabReviewWebhook({ + payload: {}, + headers: {}, + platforms: { + gitlab: { + enabled: true, + settings: { + 'review.enabled': false, + }, + }, + }, + secrets: memorySecrets, + })).resolves.toMatchObject({ + accepted: false, + httpStatus: 403, + error: 'gitlab_review_disabled', + }) + }) + + test('rejects invalid GitLab webhook token', async () => { + await expect(handleGitLabReviewWebhook({ + payload: {}, + headers: { 'x-gitlab-token': 'wrong' }, + platforms, + secrets: memorySecrets, + })).resolves.toMatchObject({ + accepted: false, + httpStatus: 401, + error: 'invalid-x-gitlab-token', + }) + }) + + test('accepts merge request webhook and builds dry-run context when changes are supplied', async () => { + const result = await handleGitLabReviewWebhook({ + payload: { + object_kind: 'merge_request', + project: { + id: 123, + path_with_namespace: 'nine1/nine1bot', + web_url: 'https://gitlab.example.com/nine1/nine1bot', + }, + object_attributes: { + iid: 10, + last_commit: { id: 'abc123' }, + }, + changes: { + changes: [ + { old_path: 'src/app.ts', new_path: 'src/app.ts', diff: '@@ -1 +1 @@\n-a\n+b\n' }, + ], + }, + }, + headers: { 'x-gitlab-token': 'secret' }, + platforms, + secrets: memorySecrets, + }) + + expect(result).toMatchObject({ + accepted: true, + status: 'dry-run', + idempotencyKey: 'gitlab:gitlab.example.com:123:mr:10:head_sha:abc123:auto:merge_request', + }) + expect(result.accepted && result.context?.diff.stats.includedFileCount).toBe(1) + }) +}) diff --git a/packages/nine1bot/src/review/gitlab-controller.ts b/packages/nine1bot/src/review/gitlab-controller.ts new file mode 100644 index 00000000..902e001b --- /dev/null +++ b/packages/nine1bot/src/review/gitlab-controller.ts @@ -0,0 +1,123 @@ +import { + buildGitLabReviewContext, + buildGitLabReviewIdempotencyKey, + normalizeGitLabReviewSettings, + parseGitLabWebhookEvent, + validateGitLabWebhookToken, + type GitLabRawChangesResponse, + type GitLabReviewSecretRef, + type GitLabReviewSettings, + type GitLabReviewTrigger, +} from '@nine1bot/platform-gitlab/review' +import type { PlatformManagerConfig } from '../platform/manager' +import type { PlatformSecretAccess, PlatformSecretRef } from '@nine1bot/platform-protocol' + +export type GitLabReviewWebhookInput = { + payload: unknown + headers: Record + platforms: PlatformManagerConfig + secrets: PlatformSecretAccess +} + +export type GitLabReviewWebhookResult = + | { + accepted: true + status: 'accepted' | 'dry-run' + idempotencyKey: string + trigger: GitLabReviewTrigger + context?: ReturnType + warnings: string[] + } + | { + accepted: false + status: 'rejected' + error: string + httpStatus: number + } + +export async function handleGitLabReviewWebhook(input: GitLabReviewWebhookInput): Promise { + const settings = normalizeGitLabReviewSettings(input.platforms.gitlab?.settings) + if (!settings.enabled) { + return reject(403, 'gitlab_review_disabled') + } + + const expectedSecret = await resolveGitLabReviewSecret(settings.webhookSecretRef, input.secrets) + const tokenValidation = validateGitLabWebhookToken({ + expectedSecret, + receivedToken: header(input.headers, 'x-gitlab-token'), + }) + if (!tokenValidation.ok) { + return reject(401, tokenValidation.reason ?? 'invalid_gitlab_webhook_token') + } + + const parsed = parseGitLabWebhookEvent(input.payload, settings) + if (!parsed.ok) { + return reject(202, parsed.reason) + } + + const idempotencyKey = buildGitLabReviewIdempotencyKey(parsed.trigger) + const fixtureChanges = extractDryRunChanges(input.payload) + if (settings.dryRun && fixtureChanges) { + return { + accepted: true, + status: 'dry-run', + idempotencyKey, + trigger: parsed.trigger, + context: buildGitLabReviewContext({ + trigger: parsed.trigger, + changes: fixtureChanges, + maxDiffBytes: settings.maxDiffBytes, + maxFiles: settings.maxFiles, + }), + warnings: [], + } + } + + return { + accepted: true, + status: 'accepted', + idempotencyKey, + trigger: parsed.trigger, + warnings: settings.dryRun + ? ['Dry-run payload did not include changes; live GitLab changes fetch is not wired yet.'] + : ['Runtime review execution is not wired yet.'], + } +} + +export async function resolveGitLabReviewSecret( + ref: GitLabReviewSecretRef | undefined, + secrets: PlatformSecretAccess, +): Promise { + if (!ref) return undefined + if (typeof ref === 'string') return ref + return await secrets.get(ref satisfies PlatformSecretRef) +} + +function reject(httpStatus: number, error: string): GitLabReviewWebhookResult { + return { + accepted: false, + status: 'rejected', + error, + httpStatus, + } +} + +function header(headers: Record, name: string) { + const expected = name.toLowerCase() + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === expected) return value + } + return undefined +} + +function extractDryRunChanges(payload: unknown): GitLabRawChangesResponse | undefined { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return undefined + const record = payload as Record + if (isRawChangesResponse(record.changes)) return record.changes + if (isRawChangesResponse(record.review_changes)) return record.review_changes + return undefined +} + +function isRawChangesResponse(input: unknown): input is GitLabRawChangesResponse { + return Boolean(input && typeof input === 'object' && !Array.isArray(input)) +} From 8043ed9098ab6a2ac2b36b6a2ce22a31f1f57c6c Mon Sep 17 00:00:00 2001 From: legendPei <804141866@qq.com> Date: Sat, 2 May 2026 10:45:27 +0800 Subject: [PATCH 03/60] feat(gitlab): run review workflow from webhook --- .../src/server/routes/automated-controller.ts | 2 + .../opencode/src/server/routes/webhooks.ts | 95 ++++++++++++ .../src/review/gitlab-controller.test.ts | 101 +++++++++++- .../nine1bot/src/review/gitlab-controller.ts | 144 ++++++++++++++++-- packages/nine1bot/src/review/run-store.ts | 62 ++++++++ .../src/review/context-builder.ts | 15 ++ 6 files changed, 409 insertions(+), 10 deletions(-) create mode 100644 packages/nine1bot/src/review/run-store.ts diff --git a/opencode/packages/opencode/src/server/routes/automated-controller.ts b/opencode/packages/opencode/src/server/routes/automated-controller.ts index abc3bb35..debf1de1 100644 --- a/opencode/packages/opencode/src/server/routes/automated-controller.ts +++ b/opencode/packages/opencode/src/server/routes/automated-controller.ts @@ -35,6 +35,7 @@ export type AutomatedControllerInput = { entry: RuntimeControllerProtocol.Entry clientCapabilities: RuntimeControllerProtocol.ClientCapabilities parts: RuntimeControllerProtocol.MessageSendRequest["parts"] + context?: RuntimeControllerProtocol.MessageSendRequest["context"] interactionPolicy: AutomatedInteractionPolicy timeoutMs: number timeoutMessage?: string @@ -65,6 +66,7 @@ export async function runAutomatedControllerSession(input: AutomatedControllerIn }) const messageResponse = await sendControllerMessage(sessionResponse.sessionId, { parts: input.parts, + context: input.context, entry: input.entry, clientCapabilities: input.clientCapabilities, }) diff --git a/opencode/packages/opencode/src/server/routes/webhooks.ts b/opencode/packages/opencode/src/server/routes/webhooks.ts index 7e3f661b..c8de5f1d 100644 --- a/opencode/packages/opencode/src/server/routes/webhooks.ts +++ b/opencode/packages/opencode/src/server/routes/webhooks.ts @@ -9,6 +9,8 @@ import { lazy } from "../../util/lazy" import { errors } from "../error" import { runAutomatedControllerSession, type AutomatedControllerResponse } from "./automated-controller" import { handleGitLabReviewWebhook } from "../../../../../../packages/nine1bot/src/review/gitlab-controller" +import { buildGitLabReviewRuntimePrompt } from "../../../../../../packages/nine1bot/src/review/gitlab-controller" +import { ReviewRunStore } from "../../../../../../packages/nine1bot/src/review/run-store" import { readPlatformManagerConfig } from "../../../../../../packages/nine1bot/src/platform/config-store" import { FilePlatformSecretStore } from "../../../../../../packages/nine1bot/src/platform/secrets" @@ -29,6 +31,27 @@ const WEBHOOK_ENTRY_BASE = { templateIds: ["default-user-template", "webhook-entry"], } satisfies RuntimeControllerProtocol.Entry +const GITLAB_REVIEW_CLIENT_CAPABILITIES = { + interactions: false, + permissionRequests: false, + questionRequests: false, + artifacts: false, + filePreview: false, + resourceFailures: true, + continueInWeb: true, + contextAudit: true, +} satisfies RuntimeControllerProtocol.ClientCapabilities + +const GITLAB_REVIEW_SKILLS = [ + "platform.gitlab.gitlab-mr-review-workflow", + "platform.gitlab.spec-gate-review", + "platform.gitlab.pm-risk-routing", + "platform.gitlab.review-finding-schema", + "platform.gitlab.verification-matrix", + "platform.gitlab.security-review-policy", + "platform.gitlab.gitlab-comment-rendering", +] + const RUN_MONITOR_TIMEOUT_MS = 30 * 60 * 1000 const PROMPT_PREVIEW_LIMIT = 4000 const FULL_PERMISSION_RULES: PermissionNext.Ruleset = [ @@ -358,9 +381,81 @@ async function triggerGitLabReviewWebhook(c: any) { secrets: new FilePlatformSecretStore(process.env.NINE1BOT_PLATFORM_SECRETS_PATH), }) + if (isAcceptedGitLabReviewWithContext(result) && result.status === "accepted") { + startGitLabReviewRuntimeRun(result).catch((error) => { + ReviewRunStore.update(result.runId, { + status: "failed", + error: error instanceof Error ? error.message : String(error), + }) + }) + } + return c.json(result, result.accepted ? 202 : result.httpStatus as never) } +type AcceptedGitLabReviewWithContext = Extract>, { accepted: true }> & { + context: NonNullable>, { accepted: true }>["context"]> +} + +function isAcceptedGitLabReviewWithContext( + result: Awaited>, +): result is AcceptedGitLabReviewWithContext { + return result.accepted && Boolean(result.context) +} + +async function startGitLabReviewRuntimeRun(result: AcceptedGitLabReviewWithContext) { + const directory = process.env.NINE1BOT_PROJECT_DIR || process.cwd() + const entry = { + source: "webhook", + platform: "gitlab", + mode: "gitlab-code-review", + templateIds: ["browser-gitlab", result.trigger.objectType === "mr" ? "gitlab-mr" : "gitlab-commit"], + traceId: result.runId, + } satisfies RuntimeControllerProtocol.Entry + + await runAutomatedControllerSession({ + directory, + title: `GitLab review: ${result.trigger.projectPath ?? result.trigger.projectId}`, + sessionChoice: { + agent: "platform.gitlab.pm-coordinator", + resources: { + skills: { + skills: GITLAB_REVIEW_SKILLS, + }, + }, + }, + entry, + clientCapabilities: GITLAB_REVIEW_CLIENT_CAPABILITIES, + parts: [{ type: "text", text: buildGitLabReviewRuntimePrompt(result) }], + context: { + blocks: result.context.contextBlocks, + }, + timeoutMs: RUN_MONITOR_TIMEOUT_MS, + timeoutMessage: "GitLab review run monitor timed out.", + interactionPolicy: { + permission: "deny", + question: "deny", + permissionAllowMessage: "GitLab review run allowed session permission request.", + permissionDenyMessage: "GitLab review runs are non-interactive, so permission requests are denied.", + questionDenyMessage: "Question request denied automatically in GitLab review run.", + }, + async onControllerResponse(response) { + ReviewRunStore.update(result.runId, { + status: response.accepted ? "running" : "failed", + sessionId: response.sessionID, + turnSnapshotId: response.turnSnapshotId, + ...(response.accepted ? {} : { error: "controller_message_not_accepted" }), + }) + }, + async onFinished(finished) { + ReviewRunStore.update(result.runId, { + status: finished.status === "succeeded" ? "succeeded" : "failed", + ...(finished.error ? { error: finished.error } : {}), + }) + }, + }) +} + export const WebhookPublicRoutes = lazy(() => new Hono() .post("/gitlab", triggerGitLabReviewWebhook) diff --git a/packages/nine1bot/src/review/gitlab-controller.test.ts b/packages/nine1bot/src/review/gitlab-controller.test.ts index 38d31d08..6122604e 100644 --- a/packages/nine1bot/src/review/gitlab-controller.test.ts +++ b/packages/nine1bot/src/review/gitlab-controller.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test } from 'bun:test' +import { beforeEach, describe, expect, test } from 'bun:test' import { handleGitLabReviewWebhook } from './gitlab-controller' +import { ReviewRunStore } from './run-store' import type { PlatformSecretAccess, PlatformSecretRef } from '@nine1bot/platform-protocol' const memorySecrets: PlatformSecretAccess = { @@ -13,6 +14,19 @@ const memorySecrets: PlatformSecretAccess = { }, } +const liveSecrets: PlatformSecretAccess = { + async get(ref: PlatformSecretRef) { + if (ref.key === 'gitlab-webhook') return 'secret' + if (ref.key === 'gitlab-token') return 'token' + return undefined + }, + async set() {}, + async delete() {}, + async has() { + return true + }, +} + const platforms = { gitlab: { enabled: true, @@ -35,6 +49,10 @@ const platforms = { } describe('GitLab review controller', () => { + beforeEach(() => { + ReviewRunStore.clearForTesting() + }) + test('rejects disabled GitLab review', async () => { await expect(handleGitLabReviewWebhook({ payload: {}, @@ -99,4 +117,85 @@ describe('GitLab review controller', () => { }) expect(result.accepted && result.context?.diff.stats.includedFileCount).toBe(1) }) + + test('deduplicates accepted review triggers by idempotency key', async () => { + const payload = { + object_kind: 'merge_request', + project: { + id: 123, + path_with_namespace: 'nine1/nine1bot', + web_url: 'https://gitlab.example.com/nine1/nine1bot', + }, + object_attributes: { + iid: 10, + last_commit: { id: 'abc123' }, + }, + } + + const first = await handleGitLabReviewWebhook({ + payload, + headers: { 'x-gitlab-token': 'secret' }, + platforms, + secrets: memorySecrets, + }) + const second = await handleGitLabReviewWebhook({ + payload, + headers: { 'x-gitlab-token': 'secret' }, + platforms, + secrets: memorySecrets, + }) + + expect(first.accepted && second.accepted && second.duplicateOf).toBe(first.accepted && first.runId) + }) + + test('loads live MR changes and writes blocked comments for overflow diffs', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = [] + const fetchMock = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }) + if (String(url).includes('/changes')) { + return Response.json({ + overflow: true, + changes: [{ old_path: 'src/large.ts', new_path: 'src/large.ts', diff: '', overflow: true }], + }) + } + return Response.json({ id: 1 }) + }) as typeof fetch + + const result = await handleGitLabReviewWebhook({ + payload: { + object_kind: 'merge_request', + project: { + id: 123, + web_url: 'https://gitlab.example.com/nine1/nine1bot', + }, + object_attributes: { + iid: 10, + last_commit: { id: 'overflow-sha' }, + }, + }, + headers: { 'x-gitlab-token': 'secret' }, + platforms: { + gitlab: { + enabled: true, + settings: { + ...platforms.gitlab?.settings, + 'review.dryRun': false, + 'review.baseUrl': 'https://gitlab.example.com', + }, + }, + }, + secrets: liveSecrets, + fetch: fetchMock, + }) + + expect(result).toMatchObject({ + accepted: true, + status: 'blocked', + idempotencyKey: 'gitlab:gitlab.example.com:123:mr:10:head_sha:overflow-sha:auto:merge_request', + }) + expect(calls.map((call) => call.url)).toEqual([ + 'https://gitlab.example.com/api/v4/projects/123/merge_requests/10/changes', + 'https://gitlab.example.com/api/v4/projects/123/merge_requests/10/notes', + ]) + }) }) diff --git a/packages/nine1bot/src/review/gitlab-controller.ts b/packages/nine1bot/src/review/gitlab-controller.ts index 902e001b..07dc5221 100644 --- a/packages/nine1bot/src/review/gitlab-controller.ts +++ b/packages/nine1bot/src/review/gitlab-controller.ts @@ -1,6 +1,8 @@ import { + GitLabApiClient, buildGitLabReviewContext, buildGitLabReviewIdempotencyKey, + renderBlockedDiffComment, normalizeGitLabReviewSettings, parseGitLabWebhookEvent, validateGitLabWebhookToken, @@ -9,6 +11,7 @@ import { type GitLabReviewSettings, type GitLabReviewTrigger, } from '@nine1bot/platform-gitlab/review' +import { ReviewRunStore } from './run-store' import type { PlatformManagerConfig } from '../platform/manager' import type { PlatformSecretAccess, PlatformSecretRef } from '@nine1bot/platform-protocol' @@ -17,24 +20,47 @@ export type GitLabReviewWebhookInput = { headers: Record platforms: PlatformManagerConfig secrets: PlatformSecretAccess + fetch?: typeof fetch } export type GitLabReviewWebhookResult = | { accepted: true - status: 'accepted' | 'dry-run' + status: 'accepted' | 'dry-run' | 'blocked' idempotencyKey: string + runId: string trigger: GitLabReviewTrigger context?: ReturnType warnings: string[] + duplicateOf?: string } | { accepted: false status: 'rejected' error: string httpStatus: number + runId?: string } +export function buildGitLabReviewRuntimePrompt(input: { + idempotencyKey: string + trigger: GitLabReviewTrigger + context: ReturnType +}) { + return [ + 'Run GitLab code review workflow.', + '', + `Idempotency key: ${input.idempotencyKey}`, + `Trigger: ${input.trigger.mode}`, + `Object: ${input.trigger.objectType}`, + input.trigger.objectIid ? `MR IID: ${input.trigger.objectIid}` : undefined, + input.trigger.commitSha ? `Commit SHA: ${input.trigger.commitSha}` : undefined, + input.trigger.headSha ? `Head SHA: ${input.trigger.headSha}` : undefined, + '', + 'Use the declared GitLab review skills. Produce structured review findings only from the supplied diff context. If an inline position is uncertain, prefer a top-level finding without a guessed line.', + ].filter(Boolean).join('\n') +} + export async function handleGitLabReviewWebhook(input: GitLabReviewWebhookInput): Promise { const settings = normalizeGitLabReviewSettings(input.platforms.gitlab?.settings) if (!settings.enabled) { @@ -56,27 +82,86 @@ export async function handleGitLabReviewWebhook(input: GitLabReviewWebhookInput) } const idempotencyKey = buildGitLabReviewIdempotencyKey(parsed.trigger) - const fixtureChanges = extractDryRunChanges(input.payload) - if (settings.dryRun && fixtureChanges) { + const duplicate = ReviewRunStore.findByIdempotencyKey(idempotencyKey) + if (duplicate && duplicate.status !== 'failed') { return { accepted: true, - status: 'dry-run', + status: 'accepted', idempotencyKey, + runId: duplicate.id, trigger: parsed.trigger, - context: buildGitLabReviewContext({ + warnings: ['Duplicate GitLab review trigger ignored by idempotency key.'], + duplicateOf: duplicate.id, + } + } + + const run = ReviewRunStore.create({ + platform: 'gitlab', + idempotencyKey, + status: 'accepted', + trigger: parsed.trigger as unknown as Record, + }) + + const fixtureChanges = extractDryRunChanges(input.payload) + const changes = fixtureChanges ?? await loadLiveChanges({ + trigger: parsed.trigger, + settings, + secrets: input.secrets, + fetch: input.fetch, + }) + + if (changes) { + const context = buildGitLabReviewContext({ + trigger: parsed.trigger, + changes, + maxDiffBytes: settings.maxDiffBytes, + maxFiles: settings.maxFiles, + }) + if (context.diff.blocked) { + await maybeWriteBlockedComment({ trigger: parsed.trigger, - changes: fixtureChanges, - maxDiffBytes: settings.maxDiffBytes, - maxFiles: settings.maxFiles, - }), + settings, + secrets: input.secrets, + fetch: input.fetch, + reason: context.diff.blockReason ?? 'MR diff is too large or was truncated by GitLab.', + }) + ReviewRunStore.update(run.id, { + status: 'blocked', + warnings: [context.diff.blockReason ?? 'GitLab diff blocked.'], + }) + return { + accepted: true, + status: 'blocked', + idempotencyKey, + runId: run.id, + trigger: parsed.trigger, + context, + warnings: [context.diff.blockReason ?? 'GitLab diff blocked.'], + } + } + ReviewRunStore.update(run.id, { status: settings.dryRun ? 'succeeded' : 'running' }) + return { + accepted: true, + status: settings.dryRun ? 'dry-run' : 'accepted', + idempotencyKey, + runId: run.id, + trigger: parsed.trigger, + context, warnings: [], } } + ReviewRunStore.update(run.id, { + status: settings.dryRun ? 'succeeded' : 'running', + warnings: settings.dryRun + ? ['Dry-run payload did not include changes; live GitLab changes fetch is not wired for this trigger.'] + : ['Runtime review execution is not wired yet.'], + }) return { accepted: true, status: 'accepted', idempotencyKey, + runId: run.id, trigger: parsed.trigger, warnings: settings.dryRun ? ['Dry-run payload did not include changes; live GitLab changes fetch is not wired yet.'] @@ -84,6 +169,41 @@ export async function handleGitLabReviewWebhook(input: GitLabReviewWebhookInput) } } +async function loadLiveChanges(input: { + trigger: GitLabReviewTrigger + settings: GitLabReviewSettings + secrets: PlatformSecretAccess + fetch?: typeof fetch +}): Promise { + if (input.trigger.objectType !== 'mr') return undefined + if (input.settings.dryRun) return undefined + const baseUrl = input.settings.baseUrl ?? `https://${input.trigger.host}` + const token = await resolveGitLabReviewSecret(input.settings.tokenSecretRef, input.secrets) + if (!token || !input.trigger.objectIid) return undefined + const client = new GitLabApiClient({ baseUrl, token, fetch: input.fetch }) + return await client.getMergeRequestChanges(input.trigger.projectId, input.trigger.objectIid) +} + +async function maybeWriteBlockedComment(input: { + trigger: GitLabReviewTrigger + settings: GitLabReviewSettings + secrets: PlatformSecretAccess + fetch?: typeof fetch + reason: string +}) { + if (input.settings.dryRun || input.trigger.objectType !== 'mr' || !input.trigger.objectIid) return + const token = await resolveGitLabReviewSecret(input.settings.tokenSecretRef, input.secrets) + if (!token) return + const baseUrl = input.settings.baseUrl ?? `https://${input.trigger.host}` + const client = new GitLabApiClient({ baseUrl, token, fetch: input.fetch }) + await client.createNote({ + projectId: input.trigger.projectId, + resource: 'merge_requests', + resourceId: input.trigger.objectIid, + body: renderBlockedDiffComment(input.reason), + }) +} + export async function resolveGitLabReviewSecret( ref: GitLabReviewSecretRef | undefined, secrets: PlatformSecretAccess, @@ -94,11 +214,17 @@ export async function resolveGitLabReviewSecret( } function reject(httpStatus: number, error: string): GitLabReviewWebhookResult { + const run = ReviewRunStore.create({ + platform: 'gitlab', + status: 'rejected', + error, + }) return { accepted: false, status: 'rejected', error, httpStatus, + runId: run.id, } } diff --git a/packages/nine1bot/src/review/run-store.ts b/packages/nine1bot/src/review/run-store.ts new file mode 100644 index 00000000..1129e8d0 --- /dev/null +++ b/packages/nine1bot/src/review/run-store.ts @@ -0,0 +1,62 @@ +export type ReviewRunStatus = 'accepted' | 'rejected' | 'blocked' | 'running' | 'succeeded' | 'failed' + +export type ReviewRunRecord = { + id: string + platform: 'gitlab' + idempotencyKey?: string + status: ReviewRunStatus + createdAt: number + updatedAt: number + error?: string + trigger?: Record + sessionId?: string + turnSnapshotId?: string + warnings?: string[] +} + +export type CreateReviewRunInput = Omit + +const runs = new Map() +let sequence = 0 + +export namespace ReviewRunStore { + export function create(input: CreateReviewRunInput): ReviewRunRecord { + const now = Date.now() + const run = { + ...input, + id: `review_${now.toString(36)}_${(++sequence).toString(36)}`, + createdAt: now, + updatedAt: now, + } satisfies ReviewRunRecord + runs.set(run.id, run) + return { ...run } + } + + export function findByIdempotencyKey(idempotencyKey: string): ReviewRunRecord | undefined { + for (const run of runs.values()) { + if (run.idempotencyKey === idempotencyKey) return { ...run } + } + return undefined + } + + export function update(id: string, patch: Partial>): ReviewRunRecord | undefined { + const existing = runs.get(id) + if (!existing) return undefined + const next = { + ...existing, + ...patch, + updatedAt: Date.now(), + } + runs.set(id, next) + return { ...next } + } + + export function list(): ReviewRunRecord[] { + return [...runs.values()].map((run) => ({ ...run })) + } + + export function clearForTesting() { + runs.clear() + sequence = 0 + } +} diff --git a/packages/platform-gitlab/src/review/context-builder.ts b/packages/platform-gitlab/src/review/context-builder.ts index 80d3d192..dd182b3e 100644 --- a/packages/platform-gitlab/src/review/context-builder.ts +++ b/packages/platform-gitlab/src/review/context-builder.ts @@ -8,7 +8,12 @@ export type GitLabReviewContext = { diff: ReturnType contextBlocks: Array<{ id: string + layer: 'platform' source: string + enabled: boolean + priority: number + lifecycle: 'turn' + visibility: 'system-required' content: string }> } @@ -30,12 +35,22 @@ export function buildGitLabReviewContext(input: { contextBlocks: [ { id: 'gitlab-review-trigger', + layer: 'platform', source: 'platform.gitlab.review.trigger', + enabled: true, + priority: 90, + lifecycle: 'turn', + visibility: 'system-required', content: renderTrigger(input.trigger), }, { id: 'gitlab-review-diff-manifest', + layer: 'platform', source: 'platform.gitlab.review.diff', + enabled: true, + priority: 88, + lifecycle: 'turn', + visibility: 'system-required', content: renderDiffManifest(diff), }, ], From 7e4aa9d5c8af30a6a8d40153bf50817411342217 Mon Sep 17 00:00:00 2001 From: legendPei <804141866@qq.com> Date: Sat, 2 May 2026 10:51:08 +0800 Subject: [PATCH 04/60] feat(gitlab): publish review results --- .../src/review/gitlab-controller.test.ts | 89 +++++++++++++- .../nine1bot/src/review/gitlab-controller.ts | 82 ++++++++++++- packages/nine1bot/src/review/run-store.ts | 6 + .../platform-gitlab/src/review/api-client.ts | 13 +- .../src/review/comment-renderer.ts | 2 +- packages/platform-gitlab/src/review/index.ts | 1 + .../platform-gitlab/src/review/publisher.ts | 91 ++++++++++++++ .../test/gitlab-review.test.ts | 116 ++++++++++++++++++ 8 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 packages/platform-gitlab/src/review/publisher.ts diff --git a/packages/nine1bot/src/review/gitlab-controller.test.ts b/packages/nine1bot/src/review/gitlab-controller.test.ts index 6122604e..90f67de4 100644 --- a/packages/nine1bot/src/review/gitlab-controller.test.ts +++ b/packages/nine1bot/src/review/gitlab-controller.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from 'bun:test' -import { handleGitLabReviewWebhook } from './gitlab-controller' +import { handleGitLabReviewWebhook, publishGitLabReviewRunResult } from './gitlab-controller' import { ReviewRunStore } from './run-store' import type { PlatformSecretAccess, PlatformSecretRef } from '@nine1bot/platform-protocol' @@ -198,4 +198,91 @@ describe('GitLab review controller', () => { 'https://gitlab.example.com/api/v4/projects/123/merge_requests/10/notes', ]) }) + + test('publishes runtime stage results through GitLab publisher', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = [] + const fetchMock = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }) + if (String(url).includes('/changes')) { + return Response.json({ + diff_refs: { base_sha: 'base', start_sha: 'start', head_sha: 'publish-sha' }, + changes: [{ + old_path: 'src/app.ts', + new_path: 'src/app.ts', + diff: '@@ -1,2 +1,3 @@\n context\n+changed\n', + }], + }) + } + return Response.json({ id: 1 }) + }) as typeof fetch + + const accepted = await handleGitLabReviewWebhook({ + payload: { + object_kind: 'merge_request', + project: { + id: 123, + web_url: 'https://gitlab.example.com/nine1/nine1bot', + }, + object_attributes: { + iid: 10, + last_commit: { id: 'publish-sha' }, + }, + }, + headers: { 'x-gitlab-token': 'secret' }, + platforms: { + gitlab: { + enabled: true, + settings: { + ...platforms.gitlab?.settings, + 'review.dryRun': false, + 'review.baseUrl': 'https://gitlab.example.com', + }, + }, + }, + secrets: liveSecrets, + fetch: fetchMock, + }) + + expect(accepted).toMatchObject({ accepted: true, status: 'accepted' }) + if (!accepted.accepted) throw new Error('expected accepted review run') + + const published = await publishGitLabReviewRunResult({ + runId: accepted.runId, + platforms: { + gitlab: { + enabled: true, + settings: { + ...platforms.gitlab?.settings, + 'review.dryRun': false, + 'review.baseUrl': 'https://gitlab.example.com', + }, + }, + }, + secrets: liveSecrets, + fetch: fetchMock, + stageResult: { + stage: 'verification', + status: 'ok', + summary: 'Runtime review complete.', + findings: [{ + title: 'Changed line', + body: 'Inline body', + severity: 'major', + file: 'src/app.ts', + newLine: 2, + }], + }, + }) + + expect(published).toMatchObject({ + published: true, + inlinePosted: 1, + fallbackPosted: 0, + }) + expect(calls.map((call) => call.url)).toEqual([ + 'https://gitlab.example.com/api/v4/projects/123/merge_requests/10/changes', + 'https://gitlab.example.com/api/v4/projects/123/merge_requests/10/discussions', + 'https://gitlab.example.com/api/v4/projects/123/merge_requests/10/notes', + ]) + }) }) diff --git a/packages/nine1bot/src/review/gitlab-controller.ts b/packages/nine1bot/src/review/gitlab-controller.ts index 07dc5221..434af092 100644 --- a/packages/nine1bot/src/review/gitlab-controller.ts +++ b/packages/nine1bot/src/review/gitlab-controller.ts @@ -2,6 +2,8 @@ import { GitLabApiClient, buildGitLabReviewContext, buildGitLabReviewIdempotencyKey, + parseReviewStageResult, + publishGitLabReviewResult, renderBlockedDiffComment, normalizeGitLabReviewSettings, parseGitLabWebhookEvent, @@ -42,6 +44,22 @@ export type GitLabReviewWebhookResult = runId?: string } +export type PublishGitLabReviewRunResult = + | { + published: true + runId: string + summaryPosted: boolean + inlinePosted: number + fallbackPosted: number + warnings: string[] + } + | { + published: false + runId?: string + error: string + warnings?: string[] + } + export function buildGitLabReviewRuntimePrompt(input: { idempotencyKey: string trigger: GitLabReviewTrigger @@ -128,6 +146,7 @@ export async function handleGitLabReviewWebhook(input: GitLabReviewWebhookInput) ReviewRunStore.update(run.id, { status: 'blocked', warnings: [context.diff.blockReason ?? 'GitLab diff blocked.'], + context, }) return { accepted: true, @@ -139,7 +158,7 @@ export async function handleGitLabReviewWebhook(input: GitLabReviewWebhookInput) warnings: [context.diff.blockReason ?? 'GitLab diff blocked.'], } } - ReviewRunStore.update(run.id, { status: settings.dryRun ? 'succeeded' : 'running' }) + ReviewRunStore.update(run.id, { status: settings.dryRun ? 'succeeded' : 'running', context }) return { accepted: true, status: settings.dryRun ? 'dry-run' : 'accepted', @@ -169,6 +188,67 @@ export async function handleGitLabReviewWebhook(input: GitLabReviewWebhookInput) } } +export async function publishGitLabReviewRunResult(input: { + runId: string + stageResult: unknown + platforms: PlatformManagerConfig + secrets: PlatformSecretAccess + fetch?: typeof fetch +}): Promise { + const run = ReviewRunStore.get(input.runId) + if (!run) return { published: false, runId: input.runId, error: 'review_run_not_found' } + const context = run.context as ReturnType | undefined + const trigger = run.trigger as GitLabReviewTrigger | undefined + if (!context || !trigger) return { published: false, runId: input.runId, error: 'review_run_context_missing' } + + const settings = normalizeGitLabReviewSettings(input.platforms.gitlab?.settings) + if (settings.dryRun) { + const warning = 'GitLab review result publishing skipped because dry-run is enabled.' + ReviewRunStore.update(input.runId, { status: 'succeeded', warnings: [warning] }) + return { published: false, runId: input.runId, error: 'dry_run_enabled', warnings: [warning] } + } + + const token = await resolveGitLabReviewSecret(settings.tokenSecretRef, input.secrets) + if (!token) { + ReviewRunStore.update(input.runId, { status: 'failed', error: 'gitlab_token_missing' }) + return { published: false, runId: input.runId, error: 'gitlab_token_missing' } + } + + const parsed = parseReviewStageResult(input.stageResult) + const objectId = trigger.objectType === 'mr' ? trigger.objectIid : trigger.commitSha + if (!objectId) { + ReviewRunStore.update(input.runId, { status: 'failed', error: 'gitlab_review_object_missing' }) + return { published: false, runId: input.runId, error: 'gitlab_review_object_missing' } + } + + const client = new GitLabApiClient({ + baseUrl: settings.baseUrl ?? `https://${trigger.host}`, + token, + fetch: input.fetch, + }) + const published = await publishGitLabReviewResult({ + client, + projectId: trigger.projectId, + objectType: trigger.objectType, + objectId, + manifest: context.diff, + summary: parsed.summary, + findings: parsed.findings, + inlineComments: settings.inlineComments, + warnings: parsed.nextActions, + }) + ReviewRunStore.update(input.runId, { + status: parsed.status === 'failed' ? 'failed' : 'succeeded', + warnings: published.warnings, + }) + + return { + published: true, + runId: input.runId, + ...published, + } +} + async function loadLiveChanges(input: { trigger: GitLabReviewTrigger settings: GitLabReviewSettings diff --git a/packages/nine1bot/src/review/run-store.ts b/packages/nine1bot/src/review/run-store.ts index 1129e8d0..8c20ea7d 100644 --- a/packages/nine1bot/src/review/run-store.ts +++ b/packages/nine1bot/src/review/run-store.ts @@ -12,6 +12,7 @@ export type ReviewRunRecord = { sessionId?: string turnSnapshotId?: string warnings?: string[] + context?: unknown } export type CreateReviewRunInput = Omit @@ -39,6 +40,11 @@ export namespace ReviewRunStore { return undefined } + export function get(id: string): ReviewRunRecord | undefined { + const run = runs.get(id) + return run ? { ...run } : undefined + } + export function update(id: string, patch: Partial>): ReviewRunRecord | undefined { const existing = runs.get(id) if (!existing) return undefined diff --git a/packages/platform-gitlab/src/review/api-client.ts b/packages/platform-gitlab/src/review/api-client.ts index daff9a52..9e403a89 100644 --- a/packages/platform-gitlab/src/review/api-client.ts +++ b/packages/platform-gitlab/src/review/api-client.ts @@ -17,6 +17,17 @@ export type GitLabCreateDiscussionInput = GitLabCreateNoteInput & { position?: Record } +export class GitLabApiError extends Error { + constructor( + readonly status: number, + readonly statusText: string, + readonly responseBody?: string, + ) { + super(`GitLab API request failed: ${status} ${statusText}`) + this.name = 'GitLabApiError' + } +} + export class GitLabApiClient { private readonly baseUrl: string private readonly token: string @@ -59,7 +70,7 @@ export class GitLabApiClient { }, }) if (!response.ok) { - throw new Error(`GitLab API request failed: ${response.status} ${response.statusText}`) + throw new GitLabApiError(response.status, response.statusText, await response.text().catch(() => undefined)) } return await response.json() as T } diff --git a/packages/platform-gitlab/src/review/comment-renderer.ts b/packages/platform-gitlab/src/review/comment-renderer.ts index 19b5f8c9..ff1cdc60 100644 --- a/packages/platform-gitlab/src/review/comment-renderer.ts +++ b/packages/platform-gitlab/src/review/comment-renderer.ts @@ -2,7 +2,7 @@ import type { AggregatedReviewFinding, GitLabDiffManifest } from './types' export function renderBlockedDiffComment(reason: string) { return [ - '⚠️ GitLab review blocked', + 'GitLab review blocked', '', reason, '', diff --git a/packages/platform-gitlab/src/review/index.ts b/packages/platform-gitlab/src/review/index.ts index 9b277345..4152d534 100644 --- a/packages/platform-gitlab/src/review/index.ts +++ b/packages/platform-gitlab/src/review/index.ts @@ -7,6 +7,7 @@ export * from './finding-aggregator' export * from './idempotency' export * from './inline-position' export * from './output-schema' +export * from './publisher' export * from './settings' export * from './types' export * from './webhook' diff --git a/packages/platform-gitlab/src/review/publisher.ts b/packages/platform-gitlab/src/review/publisher.ts new file mode 100644 index 00000000..8bb5fd15 --- /dev/null +++ b/packages/platform-gitlab/src/review/publisher.ts @@ -0,0 +1,91 @@ +import { GitLabApiError, type GitLabApiClient } from './api-client' +import { aggregateReviewFindings } from './finding-aggregator' +import { renderReviewSummaryComment } from './comment-renderer' +import { renderInlineFallbackFinding, validateGitLabInlinePosition } from './inline-position' +import type { GitLabDiffManifest, GitLabReviewObjectType, ReviewFinding } from './types' + +export type PublishGitLabReviewInput = { + client: Pick + projectId: string | number + objectType: GitLabReviewObjectType + objectId: string | number + manifest: GitLabDiffManifest + summary: string + findings: ReviewFinding[] + inlineComments: boolean + warnings?: string[] +} + +export type PublishGitLabReviewResult = { + summaryPosted: boolean + inlinePosted: number + fallbackPosted: number + warnings: string[] +} + +export async function publishGitLabReviewResult(input: PublishGitLabReviewInput): Promise { + const resource = resourceForObject(input.objectType) + const aggregated = aggregateReviewFindings(input.findings) + const warnings = [...(input.warnings ?? [])] + let inlinePosted = 0 + let fallbackPosted = 0 + const fallbackMarkdown: string[] = [] + + if (input.inlineComments) { + for (const finding of aggregated) { + const validation = validateGitLabInlinePosition(finding, input.manifest.files, input.manifest.diffRefs) + if (!validation.ok) { + fallbackMarkdown.push(validation.fallbackMarkdown) + fallbackPosted += 1 + warnings.push(`Inline fallback for ${finding.file ?? finding.title}: ${validation.reason}`) + continue + } + try { + await input.client.createDiscussion({ + projectId: input.projectId, + resource, + resourceId: input.objectId, + body: finding.body, + position: validation.position, + }) + inlinePosted += 1 + } catch (error) { + if (error instanceof GitLabApiError && error.status === 400) { + fallbackMarkdown.push(renderInlineFallbackFinding(finding, 'GitLab rejected the inline discussion position.')) + fallbackPosted += 1 + warnings.push(`Inline fallback for ${finding.file ?? finding.title}: GitLab API returned 400.`) + continue + } + throw error + } + } + } + + const summaryBody = [ + renderReviewSummaryComment({ + summary: input.summary, + findings: aggregated, + manifest: input.manifest, + warnings, + }), + fallbackMarkdown.length ? ['', '### Inline Fallbacks', '', fallbackMarkdown.join('\n\n')].join('\n') : undefined, + ].filter(Boolean).join('\n') + + await input.client.createNote({ + projectId: input.projectId, + resource, + resourceId: input.objectId, + body: summaryBody, + }) + + return { + summaryPosted: true, + inlinePosted, + fallbackPosted, + warnings, + } +} + +function resourceForObject(objectType: GitLabReviewObjectType): 'merge_requests' | 'repository/commits' { + return objectType === 'mr' ? 'merge_requests' : 'repository/commits' +} diff --git a/packages/platform-gitlab/test/gitlab-review.test.ts b/packages/platform-gitlab/test/gitlab-review.test.ts index 39d3aaf6..9d5cd207 100644 --- a/packages/platform-gitlab/test/gitlab-review.test.ts +++ b/packages/platform-gitlab/test/gitlab-review.test.ts @@ -5,7 +5,9 @@ import { buildGitLabReviewContext, buildGitLabReviewIdempotencyKey, defaultGitLabReviewSettings, + GitLabApiError, parseGitLabWebhookEvent, + publishGitLabReviewResult, validateGitLabInlinePosition, validateGitLabWebhookToken, type GitLabRawChangesResponse, @@ -168,4 +170,118 @@ describe('GitLab review foundation', () => { 'platform.gitlab.review.diff', ]) }) + + test('publishes valid inline comments and one summary note', async () => { + const manifest = buildGitLabDiffManifest({ + diff_refs: { base_sha: 'base', start_sha: 'start', head_sha: 'head' }, + changes: [{ + old_path: 'src/app.ts', + new_path: 'src/app.ts', + diff: '@@ -1,2 +1,3 @@\n context\n+changed\n', + }], + }) + const calls: string[] = [] + const result = await publishGitLabReviewResult({ + client: { + async createDiscussion() { + calls.push('discussion') + return {} + }, + async createNote() { + calls.push('note') + return {} + }, + }, + projectId: 123, + objectType: 'mr', + objectId: 10, + manifest, + summary: 'Review complete.', + inlineComments: true, + findings: [{ + title: 'Changed line', + body: 'Inline body', + severity: 'major', + file: 'src/app.ts', + newLine: 2, + }], + }) + + expect(result).toMatchObject({ summaryPosted: true, inlinePosted: 1, fallbackPosted: 0 }) + expect(calls).toEqual(['discussion', 'note']) + }) + + test('falls back to summary note when inline line is outside diff hunks', async () => { + const manifest = buildGitLabDiffManifest({ + changes: [{ + old_path: 'src/app.ts', + new_path: 'src/app.ts', + diff: '@@ -1,2 +1,3 @@\n context\n+changed\n', + }], + }) + const notes: string[] = [] + const result = await publishGitLabReviewResult({ + client: { + async createDiscussion() { + throw new Error('should not post inline') + }, + async createNote(input) { + notes.push(input.body) + return {} + }, + }, + projectId: 123, + objectType: 'mr', + objectId: 10, + manifest, + summary: 'Review complete.', + inlineComments: true, + findings: [{ + title: 'Context line', + body: 'Fallback body', + severity: 'major', + file: 'src/app.ts', + newLine: 1, + }], + }) + + expect(result.fallbackPosted).toBe(1) + expect(notes[0]).toContain('Inline Fallbacks') + }) + + test('falls back to summary note when GitLab rejects inline position', async () => { + const manifest = buildGitLabDiffManifest({ + changes: [{ + old_path: 'src/app.ts', + new_path: 'src/app.ts', + diff: '@@ -1,2 +1,3 @@\n context\n+changed\n', + }], + }) + const result = await publishGitLabReviewResult({ + client: { + async createDiscussion() { + throw new GitLabApiError(400, 'Bad Request') + }, + async createNote() { + return {} + }, + }, + projectId: 123, + objectType: 'mr', + objectId: 10, + manifest, + summary: 'Review complete.', + inlineComments: true, + findings: [{ + title: 'Changed line', + body: 'Inline body', + severity: 'major', + file: 'src/app.ts', + newLine: 2, + }], + }) + + expect(result).toMatchObject({ inlinePosted: 0, fallbackPosted: 1 }) + expect(result.warnings[0]).toContain('GitLab API returned 400') + }) }) From afda18320749373520db00e142eaeb6d12ba01dd Mon Sep 17 00:00:00 2001 From: legendPei <804141866@qq.com> Date: Sat, 2 May 2026 10:51:55 +0800 Subject: [PATCH 05/60] feat(gitlab): expose review run publish api --- .../opencode/src/server/routes/webhooks.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/opencode/packages/opencode/src/server/routes/webhooks.ts b/opencode/packages/opencode/src/server/routes/webhooks.ts index c8de5f1d..feb5c3ee 100644 --- a/opencode/packages/opencode/src/server/routes/webhooks.ts +++ b/opencode/packages/opencode/src/server/routes/webhooks.ts @@ -8,7 +8,7 @@ import z from "zod" import { lazy } from "../../util/lazy" import { errors } from "../error" import { runAutomatedControllerSession, type AutomatedControllerResponse } from "./automated-controller" -import { handleGitLabReviewWebhook } from "../../../../../../packages/nine1bot/src/review/gitlab-controller" +import { handleGitLabReviewWebhook, publishGitLabReviewRunResult } from "../../../../../../packages/nine1bot/src/review/gitlab-controller" import { buildGitLabReviewRuntimePrompt } from "../../../../../../packages/nine1bot/src/review/gitlab-controller" import { ReviewRunStore } from "../../../../../../packages/nine1bot/src/review/run-store" import { readPlatformManagerConfig } from "../../../../../../packages/nine1bot/src/platform/config-store" @@ -52,6 +52,10 @@ const GITLAB_REVIEW_SKILLS = [ "platform.gitlab.gitlab-comment-rendering", ] +const GitLabReviewPublishBody = z.object({ + stageResult: z.unknown(), +}).strict() + const RUN_MONITOR_TIMEOUT_MS = 30 * 60 * 1000 const PROMPT_PREVIEW_LIMIT = 4000 const FULL_PERMISSION_RULES: PermissionNext.Ruleset = [ @@ -549,5 +553,33 @@ export const WebhookRoutes = lazy(() => }), ), async (c) => c.json(await Webhook.listRuns(c.req.valid("query"))), + ) + .get("/gitlab/runs", async (c) => { + return c.json({ + runs: ReviewRunStore.list(), + }) + }) + .get( + "/gitlab/runs/:runId", + validator("param", z.object({ runId: z.string() })), + async (c) => { + const run = ReviewRunStore.get(c.req.valid("param").runId) + if (!run) return c.json({ error: "review_run_not_found" }, 404) + return c.json(run) + }, + ) + .post( + "/gitlab/runs/:runId/publish", + validator("param", z.object({ runId: z.string() })), + validator("json", GitLabReviewPublishBody), + async (c) => { + const result = await publishGitLabReviewRunResult({ + runId: c.req.valid("param").runId, + stageResult: c.req.valid("json").stageResult, + platforms: await readPlatformManagerConfig(), + secrets: new FilePlatformSecretStore(process.env.NINE1BOT_PLATFORM_SECRETS_PATH), + }) + return c.json(result, result.published ? 200 : 400) + }, ), ) From 37da37c52b355bd457bb99f387e0751f8dc12bfd Mon Sep 17 00:00:00 2001 From: legendPei <804141866@qq.com> Date: Sat, 2 May 2026 10:54:49 +0800 Subject: [PATCH 06/60] docs(gitlab): record review implementation state --- .../04-current-state-and-next-plan.md | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md diff --git a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md new file mode 100644 index 00000000..65125c55 --- /dev/null +++ b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md @@ -0,0 +1,225 @@ +# GitLab Review Current State And Next Plan + +## Purpose + +This document freezes the current implementation state against the original GitLab code review design, then lists the next build steps. + +The feature remains intentionally plugin-shaped: + +- GitLab-specific parsing, API access, diff rules, agents, skills, and publishing live in `packages/platform-gitlab`. +- Nine1Bot product/controller glue lives in `packages/nine1bot/src/review`. +- Runtime execution remains generic. It receives agent/session/context/resource inputs; it does not import GitLab review business types. + +## Implemented + +### Platform GitLab Foundation + +Implemented in `packages/platform-gitlab`: + +- Runtime source declaration for GitLab review assets: + - agents: `platform.gitlab`, `recommendable`, `platform-enabled` + - skills: `platform.gitlab`, `declared-only`, `platform-enabled` +- GitLab review settings in the platform descriptor: + - disabled by default + - bot mention + - webhook auto review + - inline comments + - dry-run + - allowed project ids + - webhook secret + - GitLab API token + - base URL +- Migrated review assets: + - PM primary agent: `agents/review/pm-coordinator.agent.md` + - subagent prompt skills: `skills/review/subagent-prompts/*/SKILL.md` + - workflow/policy skills under `skills/review/*/SKILL.md` +- Dry-run harness: + - `scripts/review-dry-run.ts` + - fixtures for normal and overflow MR changes. + +### Safety Rules From Design Review + +Implemented: + +- MR idempotency key includes `headSha`. +- Comment-triggered keys additionally include `noteId`. +- Diff builder filters noisy files: + - lock files + - build output + - media/static large assets + - generated files +- GitLab diff overflow/too-large blocks the review. +- Inline comments are validated against changed diff hunk lines before API calls. +- Invalid inline positions fall back to summary Markdown. +- GitLab inline API `400` falls back to summary Markdown. +- QA/Security/etc findings can be grouped deterministically by code before PM polishing. +- Runtime-facing result schema is passed as JSON-compatible schema; Runtime does not own GitLab `ReviewFinding` business types. +- Subagent task specs include `failureMode`. + +### Webhook And Controller Entry + +Implemented: + +- Public GitLab webhook entry: + - `POST /webhooks/gitlab` +- Authenticated review run inspection/publish API: + - `GET /webhooks/gitlab/runs` + - `GET /webhooks/gitlab/runs/:runId` + - `POST /webhooks/gitlab/runs/:runId/publish` +- Product/controller glue: + - `packages/nine1bot/src/review/gitlab-controller.ts` + - `packages/nine1bot/src/review/run-store.ts` +- Current run store is in-memory and intentionally small. +- Webhook flow currently does: + - validate GitLab webhook token + - parse MR/note webhook + - enforce settings and allowlist + - compute idempotency key + - dedupe accepted runs + - load live MR changes when not dry-run + - build review context + - block and write GitLab comment on overflow + - start Runtime session for non-dry-run, non-blocked runs + +### Runtime Kickoff + +Implemented: + +- Non-dry-run, non-blocked GitLab review runs start an automated Runtime session. +- The session uses: + - agent: `platform.gitlab.pm-coordinator` + - GitLab review skills as session resources + - context blocks from `GitLabReviewContext` +- Automated webhook controller input now supports `context.blocks`. + +### Result Publishing + +Implemented: + +- `publishGitLabReviewRunResult` in product/controller layer. +- `publishGitLabReviewResult` in `packages/platform-gitlab`. +- Publisher posts: + - validated inline discussions + - final top-level summary note + - inline fallback details in summary when needed +- Dry-run publishing is rejected without touching GitLab. + +## Verification + +Passing checks: + +- `bun test` in `packages/platform-gitlab` +- `bun run typecheck` in `packages/platform-gitlab` +- `bun test packages/nine1bot/src/review/gitlab-controller.test.ts` +- `bun test packages/nine1bot/src/platform/manager.test.ts` +- `bun run review:dry-run fixtures/review/sample-mr-overflow.json` + +Known verification caveat: + +- `bun run typecheck` inside `opencode/packages/opencode` still fails because that package's standalone typecheck cannot resolve workspace package `@nine1bot/platform-protocol` when it imports Nine1Bot product-layer files. This is a monorepo typecheck boundary issue already visible in the current integration pattern, not a GitLab route type error. + +## Design Comparison + +| Area | Original plan | Current state | Gap | +| --- | --- | --- | --- | +| GitLab package boundary | GitLab-specific code in `platform-gitlab` | Implemented for parsing, diff, API, publishing, skills, agents | None for Phase 0/1 | +| Agents and skills | Runtime executes PM; PM spawns custom subagents using skills | Assets registered; Runtime starts PM session | PM prompt still needs deeper adaptation for actual subagent task tool contract | +| Web setting gate | Disabled by default and configured in platform UI | Descriptor exposes config; default disabled | Web layout is generic, no GitLab-specific helper UI yet | +| Webhook trigger | GitLab MR/note webhook and `@Nine1bot` | Public `/webhooks/gitlab` parses MR and note payloads | Commit diff fetching is not live yet | +| Idempotency | Must include MR `headSha` | Implemented and tested | Store is in-memory | +| Diff safety | Filter noise and block overflow | Implemented and tested | Need more GitLab API fixture coverage for real large MR payload variants | +| Inline safety | Validate hunk, fallback on invalid/400 | Implemented and tested | None for current scope | +| Map-reduce findings | Code-side grouping before PM | Aggregator implemented | Need wire actual multi-agent stage outputs into aggregator | +| Runtime boundary | Runtime accepts generic schema/results | Platform/controller own review types | Need actual PM result capture path | +| Failure policy | `failureMode` on subagent specs | Type and initial task specs exist | Runtime PM subagent creation contract still needs implementation/confirmation | +| Dry-run harness | Required early | Implemented | Could add CLI mode for webhook payload fixtures | + +## Next Plan + +### Step 1: Runtime Result Capture + +Goal: make Runtime completion produce a structured review result that can call `publishGitLabReviewRunResult`. + +Tasks: + +- Decide the exact event or artifact channel for PM result output. +- Require PM agent to emit JSON matching `reviewStageResultJsonSchema`. +- Capture the final structured payload in the automated run monitor or controller event router. +- Call `publishGitLabReviewRunResult(runId, stageResult, ...)` on completion. +- Update `ReviewRunStore` with publish result. + +### Step 2: PM And Skill Adaptation + +Goal: make the migrated prompts truly match this project and the runtime source model. + +Tasks: + +- Rewrite `pm-coordinator.agent.md` around GitLab review, not generic implementation management. +- Tighten subagent prompt skills so they output the JSON schema consistently. +- Document allowed tools and failure modes per role. +- Keep code-writing agents disabled unless a future config explicitly allows fix mode. + +### Step 3: Live GitLab Commit Review + +Goal: support commit comment trigger beyond MR review. + +Tasks: + +- Add commit diff fetch method to `GitLabApiClient`. +- Build commit diff manifest using the same filter/overflow rules. +- Publish commit review notes via `repository/commits/:sha/notes`. +- Add fixtures and tests for commit note webhook. + +### Step 4: Persistence + +Goal: replace in-memory `ReviewRunStore`. + +Tasks: + +- Pick existing project storage pattern if available. +- Persist run records by `idempotencyKey`. +- Keep enough context for publish/retry: + - trigger + - diff refs + - manifest + - warnings + - sessionId + - turnSnapshotId + - publish status + +### Step 5: Web UX + +Goal: make GitLab review setup understandable in the platform settings UI. + +Tasks: + +- Add GitLab-specific help text or custom component if the generic platform form is not enough. +- Show webhook URL for `/webhooks/gitlab`. +- Show review run status from `GET /webhooks/gitlab/runs`. +- Surface dry-run, blocked, duplicate, and published statuses. + +### Step 6: End-To-End Test Harness + +Goal: test the whole flow without a real GitLab project. + +Tasks: + +- Extend dry-run CLI to accept webhook payload fixtures. +- Mock GitLab API fetches for changes, notes, and discussions. +- Add a script that runs: + - webhook parse + - live changes fetch mock + - Runtime prompt/context compile boundary + - publish fallback paths + +## Current Commit Stack + +Current branch: `feat/gitlab-review-workflow` + +Relevant commits: + +- `f6e439e feat(gitlab): add review workflow foundation` +- `ce7d02d feat(gitlab): add review webhook entry` +- `8043ed9 feat(gitlab): run review workflow from webhook` +- `7e4aa9d feat(gitlab): publish review results` +- `afda183 feat(gitlab): expose review run publish api` From 8f5ae3dbc4f35045ba896702b55444725db55a96 Mon Sep 17 00:00:00 2001 From: legendPei <804141866@qq.com> Date: Sat, 2 May 2026 10:59:17 +0800 Subject: [PATCH 07/60] docs(gitlab): translate implementation state plan --- .../04-current-state-and-next-plan.md | 270 +++++++++--------- 1 file changed, 136 insertions(+), 134 deletions(-) diff --git a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md index 65125c55..e2b1e1be 100644 --- a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md +++ b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md @@ -1,112 +1,113 @@ -# GitLab Review Current State And Next Plan +# GitLab Review 当前产出与下一步计划 -## Purpose +## 目标 -This document freezes the current implementation state against the original GitLab code review design, then lists the next build steps. +本文档用于固定当前 GitLab 代码审查功能的实现状态,并把它和最初的设计方案逐项对齐,避免后续继续施工时丢失上下文。 -The feature remains intentionally plugin-shaped: +当前功能仍然保持插件化边界: -- GitLab-specific parsing, API access, diff rules, agents, skills, and publishing live in `packages/platform-gitlab`. -- Nine1Bot product/controller glue lives in `packages/nine1bot/src/review`. -- Runtime execution remains generic. It receives agent/session/context/resource inputs; it does not import GitLab review business types. +- GitLab 专属解析、API、diff 规则、agents、skills、评论发布逻辑放在 `packages/platform-gitlab`。 +- Nine1Bot 产品层 controller 胶水放在 `packages/nine1bot/src/review`。 +- Runtime 仍然保持通用,只接收 agent、session、context、resource 等输入,不导入 GitLab review 业务类型。 -## Implemented +## 已完成内容 -### Platform GitLab Foundation +### GitLab 平台包基础 -Implemented in `packages/platform-gitlab`: +已在 `packages/platform-gitlab` 实现: -- Runtime source declaration for GitLab review assets: - - agents: `platform.gitlab`, `recommendable`, `platform-enabled` - - skills: `platform.gitlab`, `declared-only`, `platform-enabled` -- GitLab review settings in the platform descriptor: - - disabled by default +- GitLab review runtime source 声明: + - agents:`platform.gitlab` namespace,`recommendable`,`platform-enabled` + - skills:`platform.gitlab` namespace,`declared-only`,`platform-enabled` +- GitLab review 配置项已挂到 platform descriptor: + - 默认关闭 - bot mention - - webhook auto review - - inline comments - - dry-run + - webhook 自动审查开关 + - inline comments 开关 + - dry-run 开关 - allowed project ids - webhook secret - GitLab API token - base URL -- Migrated review assets: - - PM primary agent: `agents/review/pm-coordinator.agent.md` - - subagent prompt skills: `skills/review/subagent-prompts/*/SKILL.md` - - workflow/policy skills under `skills/review/*/SKILL.md` -- Dry-run harness: +- 已迁移 review 资产: + - PM 主代理:`agents/review/pm-coordinator.agent.md` + - 子代理 prompt skills:`skills/review/subagent-prompts/*/SKILL.md` + - workflow/policy skills:`skills/review/*/SKILL.md` +- 已提供本地 dry-run 基建: - `scripts/review-dry-run.ts` - - fixtures for normal and overflow MR changes. + - 正常 MR changes fixture + - overflow MR changes fixture -### Safety Rules From Design Review +### 设计审查中提出的安全规则 -Implemented: +已实现: -- MR idempotency key includes `headSha`. -- Comment-triggered keys additionally include `noteId`. -- Diff builder filters noisy files: - - lock files - - build output - - media/static large assets +- MR 幂等 key 包含 `headSha`。 +- 评论触发场景额外包含 `noteId`。 +- diff builder 会过滤噪声文件: + - lock 文件 + - 构建产物 + - 多媒体/静态大资源 - generated files -- GitLab diff overflow/too-large blocks the review. -- Inline comments are validated against changed diff hunk lines before API calls. -- Invalid inline positions fall back to summary Markdown. -- GitLab inline API `400` falls back to summary Markdown. -- QA/Security/etc findings can be grouped deterministically by code before PM polishing. -- Runtime-facing result schema is passed as JSON-compatible schema; Runtime does not own GitLab `ReviewFinding` business types. -- Subagent task specs include `failureMode`. +- GitLab diff overflow / too-large 会阻断审查。 +- inline comment 在调用 GitLab API 前会先校验目标行是否属于 changed diff hunk。 +- inline 行号非法时会降级为 summary Markdown。 +- GitLab inline API 返回 `400` 时会降级为 summary Markdown,不让整轮 review 失败。 +- QA / Security 等 findings 可以先用代码按文件和行号做确定性聚合,再交给 PM 润色裁决。 +- Runtime 面向的是 JSON-compatible schema/result,不拥有 `ReviewFinding` 等 GitLab review 业务类型。 +- subagent task spec 已包含 `failureMode`。 -### Webhook And Controller Entry +### Webhook 与 Controller 入口 -Implemented: +已实现: -- Public GitLab webhook entry: +- 公共 GitLab webhook 入口: - `POST /webhooks/gitlab` -- Authenticated review run inspection/publish API: +- 已认证的 review run 查询和发布 API: - `GET /webhooks/gitlab/runs` - `GET /webhooks/gitlab/runs/:runId` - `POST /webhooks/gitlab/runs/:runId/publish` -- Product/controller glue: +- 产品层 controller: - `packages/nine1bot/src/review/gitlab-controller.ts` - `packages/nine1bot/src/review/run-store.ts` -- Current run store is in-memory and intentionally small. -- Webhook flow currently does: - - validate GitLab webhook token - - parse MR/note webhook - - enforce settings and allowlist - - compute idempotency key - - dedupe accepted runs - - load live MR changes when not dry-run - - build review context - - block and write GitLab comment on overflow - - start Runtime session for non-dry-run, non-blocked runs +- 当前 `ReviewRunStore` 是进程内存实现,作为第一版最小闭环。 +- webhook 当前链路: + - 校验 GitLab webhook token + - 解析 MR / note webhook + - 应用 settings 和 allowlist + - 计算 idempotency key + - 对已 accepted 的 run 做幂等去重 + - 非 dry-run 时拉取真实 MR changes + - 构建 review context + - overflow 时阻断并向 MR 写 blocked 评论 + - 非 dry-run、未阻断时启动 Runtime session -### Runtime Kickoff +### Runtime 启动 -Implemented: +已实现: -- Non-dry-run, non-blocked GitLab review runs start an automated Runtime session. -- The session uses: - - agent: `platform.gitlab.pm-coordinator` - - GitLab review skills as session resources - - context blocks from `GitLabReviewContext` -- Automated webhook controller input now supports `context.blocks`. +- 非 dry-run 且未阻断的 GitLab review run 会启动自动化 Runtime session。 +- session 使用: + - agent:`platform.gitlab.pm-coordinator` + - GitLab review skills 作为 session resources + - `GitLabReviewContext` 生成的 context blocks +- automated webhook controller 已支持传入 `context.blocks`。 -### Result Publishing +### 结果发布 -Implemented: +已实现: -- `publishGitLabReviewRunResult` in product/controller layer. -- `publishGitLabReviewResult` in `packages/platform-gitlab`. -- Publisher posts: - - validated inline discussions - - final top-level summary note - - inline fallback details in summary when needed -- Dry-run publishing is rejected without touching GitLab. +- 产品层 `publishGitLabReviewRunResult`。 +- 平台包 `publishGitLabReviewResult`。 +- publisher 会发布: + - 通过校验的 inline discussions + - 最终 top-level summary note + - inline fallback 详情 +- dry-run 下不会触碰 GitLab,会返回拒绝发布结果。 -## Verification +## 已验证命令 -Passing checks: +已通过: - `bun test` in `packages/platform-gitlab` - `bun run typecheck` in `packages/platform-gitlab` @@ -114,71 +115,71 @@ Passing checks: - `bun test packages/nine1bot/src/platform/manager.test.ts` - `bun run review:dry-run fixtures/review/sample-mr-overflow.json` -Known verification caveat: +已知验证 caveat: -- `bun run typecheck` inside `opencode/packages/opencode` still fails because that package's standalone typecheck cannot resolve workspace package `@nine1bot/platform-protocol` when it imports Nine1Bot product-layer files. This is a monorepo typecheck boundary issue already visible in the current integration pattern, not a GitLab route type error. +- 在 `opencode/packages/opencode` 内单独运行 `bun run typecheck` 仍会失败,因为该包的 standalone typecheck 当前解析不到 workspace 包 `@nine1bot/platform-protocol`,并连带把外部包类型推成 implicit any。这是当前 monorepo 跨包 typecheck 边界问题,不是 GitLab route 自身新增类型错误。 -## Design Comparison +## 设计对比 -| Area | Original plan | Current state | Gap | +| 领域 | 原设计 | 当前状态 | 差距 | | --- | --- | --- | --- | -| GitLab package boundary | GitLab-specific code in `platform-gitlab` | Implemented for parsing, diff, API, publishing, skills, agents | None for Phase 0/1 | -| Agents and skills | Runtime executes PM; PM spawns custom subagents using skills | Assets registered; Runtime starts PM session | PM prompt still needs deeper adaptation for actual subagent task tool contract | -| Web setting gate | Disabled by default and configured in platform UI | Descriptor exposes config; default disabled | Web layout is generic, no GitLab-specific helper UI yet | -| Webhook trigger | GitLab MR/note webhook and `@Nine1bot` | Public `/webhooks/gitlab` parses MR and note payloads | Commit diff fetching is not live yet | -| Idempotency | Must include MR `headSha` | Implemented and tested | Store is in-memory | -| Diff safety | Filter noise and block overflow | Implemented and tested | Need more GitLab API fixture coverage for real large MR payload variants | -| Inline safety | Validate hunk, fallback on invalid/400 | Implemented and tested | None for current scope | -| Map-reduce findings | Code-side grouping before PM | Aggregator implemented | Need wire actual multi-agent stage outputs into aggregator | -| Runtime boundary | Runtime accepts generic schema/results | Platform/controller own review types | Need actual PM result capture path | -| Failure policy | `failureMode` on subagent specs | Type and initial task specs exist | Runtime PM subagent creation contract still needs implementation/confirmation | -| Dry-run harness | Required early | Implemented | Could add CLI mode for webhook payload fixtures | +| GitLab 包边界 | GitLab 专属代码放在 `platform-gitlab` | parsing、diff、API、publishing、skills、agents 已放入 | Phase 0/1 无明显差距 | +| Agents / skills | Runtime 执行 PM,PM 用 skills 创建自定义子代理 | 资产已注册,Runtime 已能启动 PM session | PM prompt 还需要进一步适配真实 subagent task tool contract | +| Web 配置开关 | 默认关闭,通过平台设置启用 | descriptor 已暴露配置项,默认关闭 | 还没有 GitLab 专属引导 UI | +| Webhook 触发 | GitLab MR / note webhook 与 `@Nine1bot` | `/webhooks/gitlab` 已解析 MR 和 note payload | commit diff live fetch 还没接 | +| 幂等性 | MR key 必须包含 `headSha` | 已实现并测试 | store 仍是内存实现 | +| Diff 安全 | 过滤噪声,overflow 阻断 | 已实现并测试 | 需要更多真实 GitLab 大 MR payload fixture | +| Inline 安全 | 校验 hunk,非法或 400 fallback | 已实现并测试 | 当前阶段无明显差距 | +| Map-reduce findings | 代码侧聚合后交给 PM | aggregator 已实现 | 尚未接真实多 agent stage outputs | +| Runtime 边界 | Runtime 只处理通用 schema/result | review 类型由 platform/controller 拥有 | 还需要接 PM 最终结构化结果捕获 | +| Failure policy | subagent spec 包含 `failureMode` | 类型和初始 task specs 已有 | Runtime 内 PM 创建子代理的实际 tool contract 仍需确认/实现 | +| Dry-run harness | 初期必须有 | 已实现 | 可继续扩展 webhook payload fixture 模式 | -## Next Plan +## 下一步计划 -### Step 1: Runtime Result Capture +### 1. Runtime 结果捕获 -Goal: make Runtime completion produce a structured review result that can call `publishGitLabReviewRunResult`. +目标:Runtime 执行完成后,能产生结构化 review result,并自动调用 `publishGitLabReviewRunResult`。 -Tasks: +任务: -- Decide the exact event or artifact channel for PM result output. -- Require PM agent to emit JSON matching `reviewStageResultJsonSchema`. -- Capture the final structured payload in the automated run monitor or controller event router. -- Call `publishGitLabReviewRunResult(runId, stageResult, ...)` on completion. -- Update `ReviewRunStore` with publish result. +- 确定 PM 最终结构化输出走 event、artifact 还是 controller response。 +- 要求 PM agent 输出符合 `reviewStageResultJsonSchema` 的 JSON。 +- 在 automated run monitor 或 controller event router 中捕获最终 payload。 +- completion 时调用 `publishGitLabReviewRunResult(runId, stageResult, ...)`。 +- 把发布结果写回 `ReviewRunStore`。 -### Step 2: PM And Skill Adaptation +### 2. PM 与 skills 适配 -Goal: make the migrated prompts truly match this project and the runtime source model. +目标:让迁移来的 prompts 真正适配当前项目和 runtime source 模型。 -Tasks: +任务: -- Rewrite `pm-coordinator.agent.md` around GitLab review, not generic implementation management. -- Tighten subagent prompt skills so they output the JSON schema consistently. -- Document allowed tools and failure modes per role. -- Keep code-writing agents disabled unless a future config explicitly allows fix mode. +- 重写 `pm-coordinator.agent.md`,聚焦 GitLab review,而不是泛化实现管理。 +- 收紧各 subagent prompt skill,让它们稳定输出统一 JSON schema。 +- 明确每个角色的 allowed tools 和 failure modes。 +- 除非未来配置显式开启 fix mode,否则代码修改类 agent 默认不执行写操作。 -### Step 3: Live GitLab Commit Review +### 3. GitLab Commit Review -Goal: support commit comment trigger beyond MR review. +目标:支持 commit 评论触发场景。 -Tasks: +任务: -- Add commit diff fetch method to `GitLabApiClient`. -- Build commit diff manifest using the same filter/overflow rules. -- Publish commit review notes via `repository/commits/:sha/notes`. -- Add fixtures and tests for commit note webhook. +- 在 `GitLabApiClient` 增加 commit diff 拉取方法。 +- commit diff 复用当前 filter / overflow guard。 +- 通过 `repository/commits/:sha/notes` 发布 commit review note。 +- 增加 commit note webhook fixture 和测试。 -### Step 4: Persistence +### 4. ReviewRun 持久化 -Goal: replace in-memory `ReviewRunStore`. +目标:替换当前内存版 `ReviewRunStore`。 -Tasks: +任务: -- Pick existing project storage pattern if available. -- Persist run records by `idempotencyKey`. -- Keep enough context for publish/retry: +- 复用项目已有存储模式。 +- 按 `idempotencyKey` 持久化 run record。 +- 保留发布/重试所需上下文: - trigger - diff refs - manifest @@ -187,39 +188,40 @@ Tasks: - turnSnapshotId - publish status -### Step 5: Web UX +### 5. Web UX -Goal: make GitLab review setup understandable in the platform settings UI. +目标:让用户能清楚配置 GitLab review。 -Tasks: +任务: -- Add GitLab-specific help text or custom component if the generic platform form is not enough. -- Show webhook URL for `/webhooks/gitlab`. -- Show review run status from `GET /webhooks/gitlab/runs`. -- Surface dry-run, blocked, duplicate, and published statuses. +- 如果 generic platform form 不够清晰,增加 GitLab 专属帮助文案或 custom component。 +- 展示 webhook URL:`/webhooks/gitlab`。 +- 展示 review run 状态:`GET /webhooks/gitlab/runs`。 +- 展示 dry-run、blocked、duplicate、published 等状态。 -### Step 6: End-To-End Test Harness +### 6. 端到端测试桩 -Goal: test the whole flow without a real GitLab project. +目标:不用真实 GitLab 项目也能跑通完整链路。 -Tasks: +任务: -- Extend dry-run CLI to accept webhook payload fixtures. -- Mock GitLab API fetches for changes, notes, and discussions. -- Add a script that runs: +- 扩展 dry-run CLI,支持 webhook payload fixtures。 +- mock GitLab API 的 changes、notes、discussions。 +- 增加一条脚本串起: - webhook parse - live changes fetch mock - Runtime prompt/context compile boundary - publish fallback paths -## Current Commit Stack +## 当前提交栈 -Current branch: `feat/gitlab-review-workflow` +当前分支:`feat/gitlab-review-workflow` -Relevant commits: +相关提交: - `f6e439e feat(gitlab): add review workflow foundation` - `ce7d02d feat(gitlab): add review webhook entry` - `8043ed9 feat(gitlab): run review workflow from webhook` - `7e4aa9d feat(gitlab): publish review results` - `afda183 feat(gitlab): expose review run publish api` +- `37da37c docs(gitlab): record review implementation state` From f05fef403590db70d5ca3c8f6aa2e4198284bf9a Mon Sep 17 00:00:00 2001 From: legendPei <804141866@qq.com> Date: Sat, 2 May 2026 11:05:26 +0800 Subject: [PATCH 08/60] feat(gitlab): capture runtime review results --- .../src/server/routes/automated-controller.ts | 40 +++++++++++++- .../opencode/src/server/routes/webhooks.ts | 28 +++++++++- .../src/review/gitlab-controller.test.ts | 52 ++++++++++++++++++- .../nine1bot/src/review/gitlab-controller.ts | 42 +++++++++++++++ packages/nine1bot/src/review/run-store.ts | 1 + .../04-current-state-and-next-plan.md | 39 +++++++++----- 6 files changed, 185 insertions(+), 17 deletions(-) diff --git a/opencode/packages/opencode/src/server/routes/automated-controller.ts b/opencode/packages/opencode/src/server/routes/automated-controller.ts index debf1de1..32cf2936 100644 --- a/opencode/packages/opencode/src/server/routes/automated-controller.ts +++ b/opencode/packages/opencode/src/server/routes/automated-controller.ts @@ -27,6 +27,13 @@ export type AutomatedControllerResponse = { response: RuntimeControllerProtocol.MessageSendResponse } +export type AutomatedRuntimeOutput = { + kind: "message" | "part" + sessionID: string + payload: unknown + text?: string +} + export type AutomatedControllerInput = { title: string directory: string @@ -40,6 +47,7 @@ export type AutomatedControllerInput = { timeoutMs: number timeoutMessage?: string onControllerResponse?: (response: AutomatedControllerResponse) => Promise + onRuntimeOutput?: (output: AutomatedRuntimeOutput) => Promise onFinished?: (result: { status: AutomatedRunStatus; error?: string }) => Promise onInteraction?: (interaction: { kind: "permission" | "question" @@ -93,6 +101,7 @@ export async function runAutomatedControllerSession(input: AutomatedControllerIn timeoutMs: input.timeoutMs, timeoutMessage: input.timeoutMessage, interactionPolicy: input.interactionPolicy, + onRuntimeOutput: input.onRuntimeOutput, onFinished: input.onFinished, onInteraction: input.onInteraction, }) @@ -108,6 +117,7 @@ export function startAutomatedRunMonitor(input: { timeoutMessage?: string interactionPolicy: AutomatedInteractionPolicy onFinished?: (result: { status: AutomatedRunStatus; error?: string }) => Promise + onRuntimeOutput?: AutomatedControllerInput["onRuntimeOutput"] onInteraction?: AutomatedControllerInput["onInteraction"] }) { let finished = false @@ -124,7 +134,8 @@ export function startAutomatedRunMonitor(input: { unsubscribe = Bus.subscribeAll(async (event) => { const properties = event.properties as Record | undefined - const eventSessionID = properties?.sessionID || properties?.info?.id + const eventSessionID = + properties?.sessionID || properties?.info?.sessionID || properties?.part?.sessionID || properties?.info?.id if (eventSessionID !== input.sessionID) return if (event.type === "permission.asked") { @@ -163,6 +174,27 @@ export function startAutomatedRunMonitor(input: { return } + if (event.type === "message.updated") { + const info = properties?.info + await input.onRuntimeOutput?.({ + kind: "message", + sessionID: input.sessionID, + payload: info, + }).catch(() => undefined) + return + } + + if (event.type === "message.part.updated") { + const part = properties?.part + await input.onRuntimeOutput?.({ + kind: "part", + sessionID: input.sessionID, + payload: part, + text: extractTextPart(part), + }).catch(() => undefined) + return + } + if (event.type === "session.idle") { await finish("succeeded") return @@ -193,3 +225,9 @@ function formatSessionError(error: unknown) { if (typeof error === "string") return error return JSON.stringify(error) } + +function extractTextPart(part: unknown) { + if (!part || typeof part !== "object") return undefined + const record = part as Record + return record.type === "text" && typeof record.text === "string" ? record.text : undefined +} diff --git a/opencode/packages/opencode/src/server/routes/webhooks.ts b/opencode/packages/opencode/src/server/routes/webhooks.ts index feb5c3ee..9acc3a12 100644 --- a/opencode/packages/opencode/src/server/routes/webhooks.ts +++ b/opencode/packages/opencode/src/server/routes/webhooks.ts @@ -8,7 +8,11 @@ import z from "zod" import { lazy } from "../../util/lazy" import { errors } from "../error" import { runAutomatedControllerSession, type AutomatedControllerResponse } from "./automated-controller" -import { handleGitLabReviewWebhook, publishGitLabReviewRunResult } from "../../../../../../packages/nine1bot/src/review/gitlab-controller" +import { + extractGitLabReviewStageResultFromRuntimeText, + handleGitLabReviewWebhook, + publishGitLabReviewRunResult, +} from "../../../../../../packages/nine1bot/src/review/gitlab-controller" import { buildGitLabReviewRuntimePrompt } from "../../../../../../packages/nine1bot/src/review/gitlab-controller" import { ReviewRunStore } from "../../../../../../packages/nine1bot/src/review/run-store" import { readPlatformManagerConfig } from "../../../../../../packages/nine1bot/src/platform/config-store" @@ -409,6 +413,7 @@ function isAcceptedGitLabReviewWithContext( async function startGitLabReviewRuntimeRun(result: AcceptedGitLabReviewWithContext) { const directory = process.env.NINE1BOT_PROJECT_DIR || process.cwd() + let publishAttempted = false const entry = { source: "webhook", platform: "gitlab", @@ -451,7 +456,28 @@ async function startGitLabReviewRuntimeRun(result: AcceptedGitLabReviewWithConte ...(response.accepted ? {} : { error: "controller_message_not_accepted" }), }) }, + async onRuntimeOutput(output) { + if (publishAttempted || output.kind !== "part" || !output.text) return + const stageResult = extractGitLabReviewStageResultFromRuntimeText(output.text) + if (!stageResult) return + publishAttempted = true + const published = await publishGitLabReviewRunResult({ + runId: result.runId, + stageResult, + platforms: await readPlatformManagerConfig(), + secrets: new FilePlatformSecretStore(process.env.NINE1BOT_PLATFORM_SECRETS_PATH), + }) + if (!published.published) { + ReviewRunStore.update(result.runId, { + status: "failed", + error: published.error, + warnings: published.warnings, + }) + } + }, async onFinished(finished) { + const current = ReviewRunStore.get(result.runId) + if (current?.publishedAt) return ReviewRunStore.update(result.runId, { status: finished.status === "succeeded" ? "succeeded" : "failed", ...(finished.error ? { error: finished.error } : {}), diff --git a/packages/nine1bot/src/review/gitlab-controller.test.ts b/packages/nine1bot/src/review/gitlab-controller.test.ts index 90f67de4..dcaf2595 100644 --- a/packages/nine1bot/src/review/gitlab-controller.test.ts +++ b/packages/nine1bot/src/review/gitlab-controller.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, test } from 'bun:test' -import { handleGitLabReviewWebhook, publishGitLabReviewRunResult } from './gitlab-controller' +import { + extractGitLabReviewStageResultFromRuntimeText, + handleGitLabReviewWebhook, + publishGitLabReviewRunResult, +} from './gitlab-controller' import { ReviewRunStore } from './run-store' import type { PlatformSecretAccess, PlatformSecretRef } from '@nine1bot/platform-protocol' @@ -53,6 +57,28 @@ describe('GitLab review controller', () => { ReviewRunStore.clearForTesting() }) + test('extracts runtime review results from fenced output', () => { + const extracted = extractGitLabReviewStageResultFromRuntimeText([ + 'Review complete.', + '```json', + 'GITLAB_REVIEW_RESULT:', + JSON.stringify({ + stage: 'verification', + status: 'ok', + summary: 'No blocking findings.', + findings: [], + }), + '```', + ].join('\n')) + + expect(extracted).toEqual({ + stage: 'verification', + status: 'ok', + summary: 'No blocking findings.', + findings: [], + }) + }) + test('rejects disabled GitLab review', async () => { await expect(handleGitLabReviewWebhook({ payload: {}, @@ -279,6 +305,30 @@ describe('GitLab review controller', () => { inlinePosted: 1, fallbackPosted: 0, }) + await expect(publishGitLabReviewRunResult({ + runId: accepted.runId, + platforms: { + gitlab: { + enabled: true, + settings: { + ...platforms.gitlab?.settings, + 'review.dryRun': false, + 'review.baseUrl': 'https://gitlab.example.com', + }, + }, + }, + secrets: liveSecrets, + fetch: fetchMock, + stageResult: { + stage: 'verification', + status: 'ok', + summary: 'Duplicate publish.', + findings: [], + }, + })).resolves.toMatchObject({ + published: false, + error: 'review_run_already_published', + }) expect(calls.map((call) => call.url)).toEqual([ 'https://gitlab.example.com/api/v4/projects/123/merge_requests/10/changes', 'https://gitlab.example.com/api/v4/projects/123/merge_requests/10/discussions', diff --git a/packages/nine1bot/src/review/gitlab-controller.ts b/packages/nine1bot/src/review/gitlab-controller.ts index 434af092..8051f595 100644 --- a/packages/nine1bot/src/review/gitlab-controller.ts +++ b/packages/nine1bot/src/review/gitlab-controller.ts @@ -76,9 +76,24 @@ export function buildGitLabReviewRuntimePrompt(input: { input.trigger.headSha ? `Head SHA: ${input.trigger.headSha}` : undefined, '', 'Use the declared GitLab review skills. Produce structured review findings only from the supplied diff context. If an inline position is uncertain, prefer a top-level finding without a guessed line.', + '', + 'At the end, emit exactly one fenced json block tagged GITLAB_REVIEW_RESULT. The JSON must match the GitLab review finding schema.', ].filter(Boolean).join('\n') } +export function extractGitLabReviewStageResultFromRuntimeText(text: string): unknown | undefined { + for (const candidate of extractJsonCandidates(text)) { + try { + const parsed = JSON.parse(candidate) + parseReviewStageResult(parsed) + return parsed + } catch { + continue + } + } + return undefined +} + export async function handleGitLabReviewWebhook(input: GitLabReviewWebhookInput): Promise { const settings = normalizeGitLabReviewSettings(input.platforms.gitlab?.settings) if (!settings.enabled) { @@ -197,6 +212,9 @@ export async function publishGitLabReviewRunResult(input: { }): Promise { const run = ReviewRunStore.get(input.runId) if (!run) return { published: false, runId: input.runId, error: 'review_run_not_found' } + if (run.publishedAt) { + return { published: false, runId: input.runId, error: 'review_run_already_published' } + } const context = run.context as ReturnType | undefined const trigger = run.trigger as GitLabReviewTrigger | undefined if (!context || !trigger) return { published: false, runId: input.runId, error: 'review_run_context_missing' } @@ -239,6 +257,7 @@ export async function publishGitLabReviewRunResult(input: { }) ReviewRunStore.update(input.runId, { status: parsed.status === 'failed' ? 'failed' : 'succeeded', + publishedAt: Date.now(), warnings: published.warnings, }) @@ -327,3 +346,26 @@ function extractDryRunChanges(payload: unknown): GitLabRawChangesResponse | unde function isRawChangesResponse(input: unknown): input is GitLabRawChangesResponse { return Boolean(input && typeof input === 'object' && !Array.isArray(input)) } + +function extractJsonCandidates(text: string): string[] { + const candidates: string[] = [] + const fencePattern = /```(?:json)?\s*([\s\S]*?)```/gi + for (const match of text.matchAll(fencePattern)) { + const content = match[1]?.trim() + if (content) candidates.push(stripGitLabReviewResultTag(content)) + } + + const tagged = /GITLAB_REVIEW_RESULT\s*:?\s*(\{[\s\S]*\})/i.exec(text) + if (tagged?.[1]) candidates.push(tagged[1].trim()) + + const firstBrace = text.indexOf('{') + const lastBrace = text.lastIndexOf('}') + if (firstBrace >= 0 && lastBrace > firstBrace) { + candidates.push(text.slice(firstBrace, lastBrace + 1).trim()) + } + return [...new Set(candidates)] +} + +function stripGitLabReviewResultTag(content: string) { + return content.replace(/^GITLAB_REVIEW_RESULT\s*:?\s*/i, '').trim() +} diff --git a/packages/nine1bot/src/review/run-store.ts b/packages/nine1bot/src/review/run-store.ts index 8c20ea7d..9b05281d 100644 --- a/packages/nine1bot/src/review/run-store.ts +++ b/packages/nine1bot/src/review/run-store.ts @@ -11,6 +11,7 @@ export type ReviewRunRecord = { trigger?: Record sessionId?: string turnSnapshotId?: string + publishedAt?: number warnings?: string[] context?: unknown } diff --git a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md index e2b1e1be..cf8a2dc0 100644 --- a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md +++ b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md @@ -92,6 +92,17 @@ - GitLab review skills 作为 session resources - `GitLabReviewContext` 生成的 context blocks - automated webhook controller 已支持传入 `context.blocks`。 +- automated run monitor 已能监听 `message.updated` / `message.part.updated`,并通过通用 `onRuntimeOutput` 回调暴露文本输出;这个回调仍然不包含 GitLab 业务类型。 + +### Runtime 结果捕获 + +已实现: + +- PM runtime prompt 要求最终输出一个带 `GITLAB_REVIEW_RESULT` 标记的 fenced JSON。 +- 产品层提供 `extractGitLabReviewStageResultFromRuntimeText`,只在解析后通过 `parseReviewStageResult` 校验的 JSON 才会被接受。 +- GitLab webhook runtime run 会在文本输出中捕获合法结果,并自动调用 `publishGitLabReviewRunResult`。 +- `ReviewRunStore` 增加 `publishedAt`,防止同一 run 被 streaming 输出或手动 API 重复发布。 +- session idle 时如果已经发布过结果,不再把 run 状态覆盖成普通 `succeeded`。 ### 结果发布 @@ -114,6 +125,7 @@ - `bun test packages/nine1bot/src/review/gitlab-controller.test.ts` - `bun test packages/nine1bot/src/platform/manager.test.ts` - `bun run review:dry-run fixtures/review/sample-mr-overflow.json` +- `bun run typecheck` in `opencode/packages/opencode` 已再次验证,仍只失败在 workspace 包 `@nine1bot/platform-protocol` 的 standalone 解析问题上。 已知验证 caveat: @@ -131,25 +143,14 @@ | Diff 安全 | 过滤噪声,overflow 阻断 | 已实现并测试 | 需要更多真实 GitLab 大 MR payload fixture | | Inline 安全 | 校验 hunk,非法或 400 fallback | 已实现并测试 | 当前阶段无明显差距 | | Map-reduce findings | 代码侧聚合后交给 PM | aggregator 已实现 | 尚未接真实多 agent stage outputs | -| Runtime 边界 | Runtime 只处理通用 schema/result | review 类型由 platform/controller 拥有 | 还需要接 PM 最终结构化结果捕获 | +| Runtime 边界 | Runtime 只处理通用 schema/result | review 类型由 platform/controller 拥有,自动控制器只暴露通用 runtime output | 当前阶段无明显差距 | +| Runtime 结果捕获 | PM 最终结构化结果自动发布 | 已从 `message.part.updated` 捕获 fenced JSON 并发布 | 还需要真实端到端 fixture 覆盖 streaming 与异常输出 | | Failure policy | subagent spec 包含 `failureMode` | 类型和初始 task specs 已有 | Runtime 内 PM 创建子代理的实际 tool contract 仍需确认/实现 | | Dry-run harness | 初期必须有 | 已实现 | 可继续扩展 webhook payload fixture 模式 | ## 下一步计划 -### 1. Runtime 结果捕获 - -目标:Runtime 执行完成后,能产生结构化 review result,并自动调用 `publishGitLabReviewRunResult`。 - -任务: - -- 确定 PM 最终结构化输出走 event、artifact 还是 controller response。 -- 要求 PM agent 输出符合 `reviewStageResultJsonSchema` 的 JSON。 -- 在 automated run monitor 或 controller event router 中捕获最终 payload。 -- completion 时调用 `publishGitLabReviewRunResult(runId, stageResult, ...)`。 -- 把发布结果写回 `ReviewRunStore`。 - -### 2. PM 与 skills 适配 +### 1. PM 与 skills 适配 目标:让迁移来的 prompts 真正适配当前项目和 runtime source 模型。 @@ -160,6 +161,16 @@ - 明确每个角色的 allowed tools 和 failure modes。 - 除非未来配置显式开启 fix mode,否则代码修改类 agent 默认不执行写操作。 +### 2. Runtime 结果捕获加固 + +目标:让结果捕获在真实 streaming、异常输出和重试场景下更稳。 + +任务: + +- 为 `onRuntimeOutput` 增加更贴近真实 session event 的单元或集成测试。 +- 补充 PM 输出不合法 JSON 时的 run warning / failed policy。 +- 扩展 dry-run harness,使其能注入一段 PM 输出文本并验证自动发布链路。 + ### 3. GitLab Commit Review 目标:支持 commit 评论触发场景。 From e9b2c9019084a30c74ddbe1710677236c450b5f7 Mon Sep 17 00:00:00 2001 From: legendPei <804141866@qq.com> Date: Sat, 2 May 2026 11:08:50 +0800 Subject: [PATCH 09/60] feat(gitlab): adapt review agents for runtime --- .../agents/review/pm-coordinator.agent.md | 291 +++++------------- .../04-current-state-and-next-plan.md | 29 +- .../review/gitlab-mr-review-workflow/SKILL.md | 4 + .../skills/review/pm-risk-routing/SKILL.md | 8 + .../review/review-finding-schema/SKILL.md | 27 ++ .../subagent-prompts/auto-fixer/SKILL.md | 77 ++--- .../subagent-prompts/developer/SKILL.md | 97 ++---- .../frontend-designer/SKILL.md | 151 ++------- .../review/subagent-prompts/risk-qa/SKILL.md | 154 ++------- .../subagent-prompts/security-agent/SKILL.md | 109 ++----- .../subagent-prompts/spec-writer/SKILL.md | 102 ++---- .../subagent-prompts/tech-architect/SKILL.md | 137 ++------- 12 files changed, 307 insertions(+), 879 deletions(-) diff --git a/packages/platform-gitlab/agents/review/pm-coordinator.agent.md b/packages/platform-gitlab/agents/review/pm-coordinator.agent.md index dc3e5769..0807bae2 100644 --- a/packages/platform-gitlab/agents/review/pm-coordinator.agent.md +++ b/packages/platform-gitlab/agents/review/pm-coordinator.agent.md @@ -1,4 +1,4 @@ ---- +--- name: platform.gitlab.pm-coordinator description: GitLab review PM coordinator. Primary runtime agent that restores review state, routes risk, creates custom subagents, and produces final GitLab review decisions. mode: primary @@ -7,212 +7,83 @@ permission: bash: deny --- -# 角色定义 -你是拥有丰富软件工程和项目管理经验的资深项目经理。 -你负责统一接收用户需求,恢复真实项目状态,读取代码、进度文档、长期计划文件、技术方案、需求文档、评审结论,再把任务通过 handoff 精准分配给最合适的 agent。 - -# 强制工作方式 -1. 先恢复状态,再推进执行。 - - 开工前必须先读取 `/docs/execution/progress/`、`/docs/execution/plans/`、当前任务的 Spec Bundle、`memories/pm/`、对应的业务技术方案文档和相关代码现状。 - - 如果进度文档与代码不一致,以代码与测试结果为准,再回写进度文档。 - - 若是新任务且 `specs//` 尚不存在,必须先进入 `spec` 并要求 `文档规格专家` 按 `specs/_task-template/` 创建三件套后再继续。 - - 若是新的业务技术方案,必须为该技术方案单独创建一套 Spec Bundle,不得借用其他技术方案的 spec 目录。 -2. 每次接到新需求,必须从以下维度逐项推演后再下结论:业务目标、合规要求、性能、可观测性、回滚、测试覆盖、脏数据隔离、幂等、资源释放、可运营性。 -3. 审阅 5 遍,再发起 handoff。 - - 下发任务前,必须再次检查任务边界、输入输出、涉及文件、风险点和验收标准是否完整。 -4. 尽量降低用户介入。 - - 如果没有核心业务冲突,不要频繁反问用户;优先自主读取文档、检索代码、推断上下文并安排执行。 - - 如果需求已经足够明确,禁止只停留在方案、建议、待办列表或参数确认;必须继续推进到实际执行。 -5. 验证阶段必须先做风险分级,再决定是否派发安全审查专家。 - - 若 `改动文件` 全部位于 `**/src/test/**`,或全部属于测试资源、测试夹具、E2E 脚本,则判定为"仅测试变更",安全审查可跳过。 - - 若改动仅涉及 `src/main/resources` 下的非生产 profile 配置,例如 `application-default.properties`、`application-dev*.properties`、`application-sandbox.properties`、`application-smoke.properties`、`application-test*.properties`,且未同时改动运行时代码或线上配置文件,则判定为"仅非生产配置变更",安全审查可跳过。 - - 只有改动涉及运行时代码、构建供应链、对外接口、安全边界,或生产/预生产环境配置文件时,才必须派发 `安全审查专家`。 -6. 你是唯一的主流程编排者。 - - 只有你负责决定阶段切换。 - - 除你之外,其他 agent 都没有 handoff 权,不能决定下一阶段。 - - 你必须根据上游 agent 的结构化结果决定是否进入下一阶段。 - -# 绝对禁止事项 -1. 你不得直接修改任何业务代码、测试代码、构建配置或运行配置。 -2. 你不得直接执行编译、测试、运行、发布、脚本修复等实现性命令。 -3. 你不得因为"自己已经理解需求"就跳过 `技术架构师`、`前端设计专家`、`质量保证专家`、`安全审查专家`、`代码修复专家` 的专业分工。 -4. 若任务处于 `spec` 阶段,必须交给 `文档规格专家`;若处于 `implementation` 阶段,必须根据任务性质交给 `技术架构师`(后端/全栈)或 `前端设计专家`(纯前端),若需并行可同时派发 `开发工程师`;若处于 `verification` 阶段,必须先按变更文件做风险分级:存在运行时/上线攻击面时并行派发 `质量保证专家` 和 `安全审查专家`,否则仅派发 `质量保证专家`;若处于 `fix` 阶段,必须交给 `代码修复专家`。 -5. 若需要更新 Spec Bundle、计划、进度、change log 或 repo-local memory,必须交给 `文档规格专家`,不得自行编辑。 -6. 若需求会改动某份业务技术方案覆盖的代码,PM 必须先确保该方案绑定的 Spec Bundle 已更新,且对应 `docs/dev/*.md` 技术方案已同步,再允许进入 implementation。 -7. 你不得把"模板三件套已经生成"直接等同于"Spec Bundle 已审阅通过";只有任务化内容完整且审阅通过后,才能进入 `implementation`。 - -# 默认闭环执行模式 -1. 默认目标不是"给方案",而是"在当前对话内尽可能完成完整交付"。 -2. 当需求已明确且不存在外部硬阻塞时,你必须在同一轮内尽量走完:恢复状态 -> 最小必要取证 -> spec 落文 -> PM 审阅三件套 -> 实现 -> 测试 -> 安全审查 -> 修复 -> 收口。 -2.1 对新任务,spec 落文的最低动作是先在 `specs//` 生成 `requirements.md`、`design.md`、`tasks.md`,再回填任务化内容并完成 PM 审阅。 -2.2 对已有业务技术方案的增量修改,spec 落文必须连同对应技术方案文档一起同步更新,不能只改三件套不改方案。 -3. 只有以下情况才允许中途停下并向用户要输入: - - 缺少外部凭证、环境变量、云资源、第三方接口权限。 - - 业务口径存在高风险歧义,继续实现会明显误伤业务。 - - 操作具有不可逆或高破坏性,需要用户显式授权。 -4. 如果只是存在"可选优化项"或"可配置参数",应先按仓库现有风格和保守默认值落地,再在结尾给出后续建议。 -5. 你不能把"继续执行"这件事再次交还给用户决定;你应当先执行,再在最终结论中汇报结果和下一步建议。 -6. 若某个 specialist 的执行命令超时、挂起或未返回结构化 JSON,你必须把当前阶段判定为未完成,并明确阻塞点;禁止用过程播报冒充阶段完成。 -7. 你必须把每个任务看作一个"变更单元",优先保持单一意图、单一收口路径;若目标已经实质变化,要判断是更新现有任务还是拆分新任务。 - -# 阶段枚举 -你必须把每个任务绑定到以下枚举之一,并在流转时显式更新: -1. `discovery`:需求理解、文档取证、范围确认。 -2. `spec`:requirements/design/tasks 三件套落文与 PM 审阅。 -3. `implementation`:架构设计、任务拆解与实现(可并行)。 -4. `verification`:验证阶段——PM 必须先做风险分级;存在运行时/上线攻击面时并行派发 `质量保证专家`(功能测试与代码审查)和 `安全审查专家`(安全审查),仅测试或仅非生产配置变更时只派发 `质量保证专家`,再按已派发结果统一裁决。 -5. `fix`:缺陷修复与回归准备。 -6. `closed`:结果收口、进度与变更记录完成。 - -# Handoff 路由规则 -1. 文档重、证据重、需要读 PDF/Word、需要抽取页码证据 -> `文档规格专家`(discovery 阶段) -2. 需要把确认结论写成 requirements/design/tasks 三件套 -> `文档规格专家`(spec 阶段) -3. 后端/全栈架构设计、核心业务逻辑实现、数据层设计、API 设计、消息队列/存储/数据库链路 -> `技术架构师` -4. 前端页面/组件设计与实现、UI/UX 交互、样式与响应式、前端工程化 -> `前端设计专家` -5. 架构师或前端专家拆解后的独立子任务并行实现 -> `开发工程师`(仅在 PM 确认子任务独立后才派发) -6. 覆盖测试、回归测试、E2E 脚本、并发与脏数据测试、改动代码审查、缺陷根因分析 -> `质量保证专家` -7. 合规自查、漏洞扫描分析、三方依赖安全分析、联网漏洞情报收集 -> `安全审查专家` -8. 测试失败、线上缺陷、OOM、死锁、资源泄露、回归修复、安全修复 -> `代码修复专家` -9. 需要回写进度、change log、Spec Bundle 收口状态或 repo-local memory -> `文档规格专家`(closed 阶段) - -# 阶段与角色强绑定 -1. `discovery` 只能派发给:`文档规格专家` -2. `spec` 只能派发给:`文档规格专家` -3. `implementation` 根据任务性质主线派发给:`技术架构师`(后端/全栈/数据层)或 `前端设计专家`(纯前端);当主线 agent 产出可并行子任务且 PM 确认文件无冲突后,可同时派发 `开发工程师` 并行工作;若任务同时涉及前后端,可同时派发 `技术架构师` + `前端设计专家` 并行工作 -4. `verification` 必须先做风险分级:若变更包含运行时/上线攻击面,则优先同时派发 `质量保证专家` 和 `安全审查专家` 作为两个同轮 subagent(两者无结果依赖);若仅为测试变更或仅为非生产 profile 配置变更,则只派发 `质量保证专家` 并记录安全审查跳过原因 -5. `fix` 只能派发给:`代码修复专家` -6. `closed` 记录动作只能派发给:`文档规格专家` - -# 并行实现模式 -当任务进入 `implementation` 阶段且规模较大时,PM 可启用并行实现模式: - -## 阶段一:架构设计与拆解(顺序执行) -1. PM 在 handoff prompt 约束中加入 `parallel_decomposition=true`。 -2. `技术架构师` 完成 Spec Gate → 架构审查 → 规则审查 → 数据契约审查,产出: - - 架构决策。 - - 子任务清单(每个子任务含 sub_task_id、description、files_involved、dependencies、parallel_ready)。 - - 核心/共享子任务的实现已完成。 -3. PM 接收架构师结果,进入文件冲突检查。 - -## 阶段二:文件冲突检查(PM 执行) -1. 提取所有 `parallel_ready=true` 的子任务的 `files_involved`。 -2. 检查任意两个并行子任务之间是否存在文件交集。 -3. 若存在交集 → 将冲突子任务标记为串行,由架构师依次完成。 -4. 若无交集 → 确认可安全并行。 - -## 阶段三:并行实现(条件触发) -1. PM 同时派发 `技术架构师`(承接剩余核心子任务)和 `开发工程师`(承接独立子任务)。 -2. 两者互不依赖,PM 收齐两份结果后合并。 -3. 每次并行最多 2 个实现 agent(1 架构师 + 1 开发工程师)。 -4. 若还有更多子任务,PM 进行多轮并行,直到全部完成。 - -## 阶段四:合并与验证前检查(PM 执行) -1. PM 收齐所有子任务结果后,检查: - - 是否有文件被同时修改(合并冲突)。 - - 是否有语义不兼容的改动。 - - 编译状态是否正常。 -2. 若发现冲突 → 派发 `技术架构师` 解决冲突。 -3. 若合并成功 → 进入 `verification` 阶段。 - -## 小型任务快捷路径 -- 若 PM 评估任务规模较小(单一功能、少量文件),直接派发 `技术架构师` 独立完成全部实现,不启用并行模式。 -- 这是大多数任务的默认路径。 - -# 并行验证模式 -当 `implementation` 完成且 `status=ready` 时,PM 进入并行验证模式: -1. 必须先根据 `改动文件` 判断是否存在运行时/上线攻击面。 -2. 若存在运行时/上线攻击面,优先使用 subagent 能力同时派发 `质量保证专家` 和 `安全审查专家`,让两者在同一轮并行工作;若运行时确实不支持真正并行,才允许在同一轮内连续启动两者,且不得等待前者结果再决定是否启动后者。 -3. 若仅为测试变更,或仅为非生产 profile 配置变更,跳过 `安全审查专家`,只派发 `质量保证专家`,并在进度与裁决中记录"security skipped by scope"。 -4. `质量保证专家` 专注功能测试、覆盖评估、回归验证和改动代码审查;`安全审查专家` 专注合规自查、漏洞分析与攻击面审查。两者互不等待、互不依赖对方结果。 -5. PM 收齐所需 JSON 结果后统一裁决: - - QA 通过且安全审查因范围被跳过 → 进入 `closed`。 - - QA 通过且安全审查通过 → 进入 `closed`。 - - 任一已派发验证方 `failed`,或 `质量保证专家` 报出阻塞级代码审查问题 → 合并所有问题清单与根因假设,一次性派发 `代码修复专家`,避免 qa→fix→qa→security→fix→security 的串行往返。 - - `代码修复专家` 修复完成后,仅重跑失败方(不重跑已通过方),除非修复改动显著影响了已通过方的验证范围。 -6. 并行验证模式下 `current_stage` 统一记为 `verification`;`质量保证专家` 内部仍报 `current_stage=qa`,`安全审查专家` 仍报 `current_stage=security`,PM 负责将两者统一到 `verification` 阶段进行裁决。 -7. 并行验证的效率收益:将原本 qa→security 的串行链路缩短为单阶段,同时在任一方出现问题时一次性收集全部缺陷、一次性修复,避免多轮 fix 往返。 - -# Handoff Prompt 组装规范 -1. handoff prompt 必须同时包含四部分:输入边界、默认动作、禁止项、结果格式。 -2. handoff prompt 必须要求 specialist 只做最小必要读取,避免重复搬运上下文。 -3. handoff prompt 必须要求 specialist 默认直接产出可执行结果,而不是方案、建议或待办。 -4. handoff prompt 必须要求 specialist 只在真实硬阻塞时返回 blocked 或 need_clarification。 -5. handoff prompt 必须要求 specialist 只返回统一 JSON,不先写大段说明。 -6. handoff prompt 中应尽量明确 artifacts 的子字段,减少 PM 二次解析成本。 -7. 对 `质量保证专家`、`安全审查专家` 和 `代码修复专家` 的 handoff prompt 必须明确要求输出已执行结果,避免 QA/Security/Fix 阶段再次空转。 -8. handoff prompt 必须明确要求 specialist 使用固定 summary 句式,避免 PM 再解析自由文本。 -9. handoff prompt 必须与当前阶段的强绑定角色一致;若角色不匹配,则禁止派发。 -10. 对 `verification` 阶段,handoff prompt 必须先体现风险分级结论:需要安全审查时,再明确 `质量保证专家` 与 `安全审查专家` 是并行 subagent;若因范围跳过安全审查,也必须在输入包中记录跳过依据。其中 `质量保证专家` 还必须输出改动代码的 code review 结论与阻塞问题。 -11. 对 `implementation` 并行模式,handoff prompt 必须明确 `开发工程师` 的子任务范围和文件列表,防止越界修改。 - -# 派发前最小输入包模板 -1. PM 在每次派发前必须先组装最小输入包,固定字段不得缺失: - - `task_id` - - `目标` - - `范围` - - `约束` - - `验收标准` - - `改动文件` -2. 对 `开发工程师` 的派发还必须附加: - - `sub_task_id` - - `files_involved`(子任务严格文件列表) - - `dependencies`(依赖的前置子任务 ID) - - `architecture_decisions`(架构师的设计决策摘要) -3. 若某字段当前为空,也必须显式给默认值,而不是省略。 -4. PM 不得在最小输入包缺失的情况下直接派发 specialist。 - -# 产出要求 -1. 在 `/docs/execution/plans/` 记录拆解计划。 -2. 在 `/docs/execution/progress/` 记录当前真实进度。 -3. 在 `/docs/execution/progress/agent-team-changelog.md` 记录关键变更。 -4. 在 `specs//requirements.md`、`design.md`、`tasks.md` 维护任务级 Spec Bundle。 -4.1 新任务的 Spec Bundle 必须优先参考 `specs/_task-template/` 生成,不得临时手写单文件计划替代。 -4.2 每套 Spec Bundle 必须标明对应的技术方案文档路径。 -5. 在 `memories/pm/` 维护 repo-local 长期上下文、约束和未决事项。 - -# Spec 审阅最小检查表字段 -当你审阅新任务的 Spec Bundle 时,至少必须逐项检查以下字段,任一缺失、空白、仍为模板占位文本或与当前代码现场冲突,均不得进入 `implementation`: -1. `任务目标` -1.1 `对应技术方案` -2. `业务边界` -3. `非范围说明` -4. `默认值与既有约束` -5. `受影响模块` -6. `当前检查点` -7. `最小验证计划` -8. `阻塞项` -9. `验收标准` -10. `关键文件或目录` -11. `前置依赖与外部条件` -12. `关键设计决策与取舍` -13. `规格追踪关系` -14. `当前行为基线与本次变更摘要` -15. `关键场景` -16. `技术方案同步状态` - -# SDD 审阅补充原则 -1. 你必须把 discovery 证据、spec 字段、后续实现/测试/安全/修复动作串成一条可追踪链,而不是只检查文档是否存在。 -2. 若某个 spec 字段仅基于假设而非证据,必须明确标注为假设,并决定是允许保守默认值推进,还是留在 `spec` 阶段继续澄清。 -3. 若 spec 中已经存在关键设计决策,后续 specialist 不得绕开该决策自行改写业务口径;若要变更,必须先回到 `spec`。 -4. 若需求已明确但外部条件尚未满足,必须把该条件记录到 `前置依赖与外部条件` 与 `阻塞项`,而不是让 specialist 自行猜测。 -5. 若本次工作属于对既有能力的增量修改,必须先写清"当前行为基线"和"本次 delta",再允许进入实现。 -5.1 若本次工作绑定某份业务技术方案,必须先写清"对应技术方案路径"和"本轮是否已同步技术方案",再允许进入实现。 -6. 你必须判断当前变更是继续更新现有任务,还是应该拆成新任务:若意图已改变、范围爆炸、与原任务重叠过低或原任务已可独立收口,应优先拆新任务。 -7. 进入 `closed` 前,必须确认已完成 verify 式核对:至少看 completeness、correctness、coherence 是否存在明显缺口。 -8. 进入 `closed` 前,必须确认实现、测试、安全结论已回写到 Spec Bundle、进度或 memory 中,避免代码先行而 spec 不同步。 - -# 协调口径 -1. 所有 agent 的结论先回到你这里。 -2. 你要基于结果继续 handoff,而不是把中间判断留给用户。 -3. discovery 完成后,默认先进入 `spec`,由 `文档规格专家` 落三件套。 -4. Spec Bundle 落完后,你必须先按"Spec 审阅最小检查表字段"逐项审阅三件套,且不得保留模板占位文本,并要确认已经形成 evidence -> spec -> implementation/test/security/fix 的可追踪关系。 -5. 审阅通过则自动进入 implementation,不等待人工再次指令;若未通过,继续留在 `spec` 并要求修正文档。 -6. 如果研发已完成固定顺序的内部审查与自检,才允许流转到 `verification`(并行验证)。 -7. 进入 `verification` 后,必须先按 `改动文件` 做风险分级;涉及运行时/上线攻击面时并行派发 `质量保证专家` 和 `安全审查专家`,否则只派发 `质量保证专家` 并记录安全审查跳过原因。收齐所需结果后再统一裁决。 -8. 若修复后重跑仍失败,由你决定是否继续修复或升级为阻塞。 -9. 你必须检查"当前任务所处阶段",禁止把同一任务再次发往相同阶段。 -10. 你必须读取上游 agent 的 `recommended_next_stage`,但不能机械照做,必须结合当前状态再判断。 -11. 只要任务仍可继续推进,你就必须继续推进;不要在"已有实施路径"时只输出方案摘要。 -12. 你的最终输出应当默认是"已完成了什么、验证结果如何、剩余风险和下一步是什么",而不是"建议用户下一步让我开始干活"。 -13. 你的职责是分派、审核、收口,不是实现、测试、修复或写业务代码。 - +# GitLab Review PM Coordinator + +你是 GitLab 代码审查工作流的主代理。你的职责是读取 Runtime 注入的 GitLab review context,只基于本次 MR/Commit diff 进行审查编排,必要时创建自定义子代理,并最终输出一个可由平台发布器解析的结构化 JSON。 + +你不是实现代理。默认情况下禁止修改仓库文件、禁止运行修复脚本、禁止把审查任务扩展成通用开发任务。除非输入 context 明确给出 `fixMode=true`,否则所有结论都只能作为 review findings 输出。 + +## 输入来源 + +优先使用 Runtime context blocks 中的内容: + +1. trigger:GitLab host、projectId、MR IID 或 commit SHA、headSha、noteId、触发方式。 +2. diff manifest:included files、skipped files、diff refs、统计信息、blocked 状态。 +3. review policy:inline comment 约束、filtered file 说明、allowed project/host 约束。 +4. skills:GitLab review workflow、risk routing、finding schema、security policy、comment rendering。 + +如果 context 显示 diff 已 blocked、overflow、too large 或 included files 为空,不要继续审查具体代码,直接输出 `status="blocked"`。 + +## 工作流 + +1. `discovery` + - 识别本次 diff 的文件类型、风险域、跳过文件和已知约束。 + - 不要猜测 diff 外代码行为;缺少证据时写入 `nextActions`,不要制造 finding。 +2. `spec` + - 判断是否有足够上下文进行代码审查。 + - 对 GitLab review 而言,spec gate 是“diff 和 context 是否足够支撑审查”,不是要求仓库存在 specs 三件套。 +3. `implementation` + - 这里表示“实现面审查”,不是修改代码。 + - 根据风险创建自定义子代理: + - 架构/运行时边界/API/持久化/config:`platform.gitlab.subagent-prompts.tech-architect` + - 前端 UI/状态/浏览器行为:`platform.gitlab.subagent-prompts.frontend-designer` + - 行为正确性/测试缺口/回归风险:`platform.gitlab.subagent-prompts.risk-qa` + - 鉴权/凭证/命令执行/网络/供应链/数据泄露:`platform.gitlab.subagent-prompts.security-agent` + - 小 MR 可以不创建子代理,由你直接完成审查。 +4. `verification` + - 用代码确定性规则先合并同文件同一行的 findings,再由你做严重级别裁决。 + - 子代理超时或失败时按 failureMode 处理:`abort-run` 阻断;`ignore` 在 `nextActions` 说明;`fallback` 用已知证据给出保守结论。 +5. `closed` + - 输出最终 JSON。不要输出额外解释盖过 JSON。 + +## Finding 规则 + +1. 只报告可由 diff 或 context 直接支撑的问题。 +2. `file`、`oldLine`、`newLine` 只有在 diff hunk 中有根据时才填写。 +3. 不确定行号时只填写 `file` 或不填位置,让平台发布器写入 summary fallback。 +4. 严重级别: + - `blocker`:会导致数据损坏、权限绕过、远程执行、发布阻断或主要功能不可用。 + - `critical`:高概率生产事故、安全漏洞或重大回归。 + - `major`:明确缺陷、重要边界遗漏或测试无法证明安全。 + - `minor`:局部质量问题或低风险边界。 + - `info`:非阻断提示。 +5. 不输出泛泛建议、风格偏好、diff 外猜测和无法验证的最佳实践。 + +## 最终输出格式 + +最后必须输出且只输出一个 fenced JSON block,第一行使用 `GITLAB_REVIEW_RESULT:` 标记。JSON 必须匹配以下结构: + +```json +GITLAB_REVIEW_RESULT: +{ + "stage": "closed", + "status": "ok", + "summary": "简短总结本次审查结论。", + "findings": [ + { + "title": "问题标题", + "body": "证据、影响和建议修改方式。", + "severity": "major", + "category": "correctness", + "file": "src/example.ts", + "newLine": 42, + "source": "pm-coordinator" + } + ], + "nextActions": [ + "可选的人工复核或后续动作" + ] +} +``` + +`status` 只能是 `ok`、`blocked`、`failed`。没有发现问题时 `findings` 输出空数组。被 diff guard 阻断时使用 `blocked`。子代理或审查执行失败且无法形成可靠结论时使用 `failed`。 diff --git a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md index cf8a2dc0..36eb9ef2 100644 --- a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md +++ b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md @@ -33,6 +33,11 @@ - PM 主代理:`agents/review/pm-coordinator.agent.md` - 子代理 prompt skills:`skills/review/subagent-prompts/*/SKILL.md` - workflow/policy skills:`skills/review/*/SKILL.md` +- PM 与子代理 prompt 已适配为 GitLab review 专用模式: + - 默认只读审查,不把任务扩展成通用实现。 + - PM 只负责编排、裁决和最终 ReviewStageResult 输出。 + - 子代理按架构、前端、QA、安全、上下文取证等角色输出统一 JSON findings。 + - developer / auto-fixer 默认 blocked,只有显式 `fixMode=true` 才允许写操作。 - 已提供本地 dry-run 基建: - `scripts/review-dry-run.ts` - 正常 MR changes fixture @@ -136,7 +141,7 @@ | 领域 | 原设计 | 当前状态 | 差距 | | --- | --- | --- | --- | | GitLab 包边界 | GitLab 专属代码放在 `platform-gitlab` | parsing、diff、API、publishing、skills、agents 已放入 | Phase 0/1 无明显差距 | -| Agents / skills | Runtime 执行 PM,PM 用 skills 创建自定义子代理 | 资产已注册,Runtime 已能启动 PM session | PM prompt 还需要进一步适配真实 subagent task tool contract | +| Agents / skills | Runtime 执行 PM,PM 用 skills 创建自定义子代理 | 资产已注册,PM/子代理 prompt 已收紧为 GitLab review 只读模式 | 还需要真实 subagent task tool contract 的端到端验证 | | Web 配置开关 | 默认关闭,通过平台设置启用 | descriptor 已暴露配置项,默认关闭 | 还没有 GitLab 专属引导 UI | | Webhook 触发 | GitLab MR / note webhook 与 `@Nine1bot` | `/webhooks/gitlab` 已解析 MR 和 note payload | commit diff live fetch 还没接 | | 幂等性 | MR key 必须包含 `headSha` | 已实现并测试 | store 仍是内存实现 | @@ -150,18 +155,7 @@ ## 下一步计划 -### 1. PM 与 skills 适配 - -目标:让迁移来的 prompts 真正适配当前项目和 runtime source 模型。 - -任务: - -- 重写 `pm-coordinator.agent.md`,聚焦 GitLab review,而不是泛化实现管理。 -- 收紧各 subagent prompt skill,让它们稳定输出统一 JSON schema。 -- 明确每个角色的 allowed tools 和 failure modes。 -- 除非未来配置显式开启 fix mode,否则代码修改类 agent 默认不执行写操作。 - -### 2. Runtime 结果捕获加固 +### 1. Runtime 结果捕获加固 目标:让结果捕获在真实 streaming、异常输出和重试场景下更稳。 @@ -170,8 +164,9 @@ - 为 `onRuntimeOutput` 增加更贴近真实 session event 的单元或集成测试。 - 补充 PM 输出不合法 JSON 时的 run warning / failed policy。 - 扩展 dry-run harness,使其能注入一段 PM 输出文本并验证自动发布链路。 +- 验证 PM 通过 runtime subagent/task 能力创建自定义子代理时,promptRef、skills、timeout、failureMode 能被正确传入。 -### 3. GitLab Commit Review +### 2. GitLab Commit Review 目标:支持 commit 评论触发场景。 @@ -182,7 +177,7 @@ - 通过 `repository/commits/:sha/notes` 发布 commit review note。 - 增加 commit note webhook fixture 和测试。 -### 4. ReviewRun 持久化 +### 3. ReviewRun 持久化 目标:替换当前内存版 `ReviewRunStore`。 @@ -199,7 +194,7 @@ - turnSnapshotId - publish status -### 5. Web UX +### 4. Web UX 目标:让用户能清楚配置 GitLab review。 @@ -210,7 +205,7 @@ - 展示 review run 状态:`GET /webhooks/gitlab/runs`。 - 展示 dry-run、blocked、duplicate、published 等状态。 -### 6. 端到端测试桩 +### 5. 端到端测试桩 目标:不用真实 GitLab 项目也能跑通完整链路。 diff --git a/packages/platform-gitlab/skills/review/gitlab-mr-review-workflow/SKILL.md b/packages/platform-gitlab/skills/review/gitlab-mr-review-workflow/SKILL.md index cf539cd9..af4e8ecb 100644 --- a/packages/platform-gitlab/skills/review/gitlab-mr-review-workflow/SKILL.md +++ b/packages/platform-gitlab/skills/review/gitlab-mr-review-workflow/SKILL.md @@ -7,6 +7,8 @@ description: Use for GitLab merge request review runs triggered by @Nine1bot com Treat the GitLab merge request as the source of truth for scope. Review only the included diff manifest and provided repository context. +This is a read-only review workflow by default. Do not edit files, run fix scripts, or turn the review into a general implementation task unless the PM input explicitly sets `fixMode=true`. + Stage order: 1. discovery: identify changed files, risk areas, evidence, assumptions, and blocked conditions. @@ -18,3 +20,5 @@ Stage order: Never invent findings outside the diff. If the diff is blocked, truncated, or empty after filters, stop and report the blocked state. +The PM coordinator must finish with one fenced JSON block tagged `GITLAB_REVIEW_RESULT`. The JSON must match the ReviewStageResult schema from `platform.gitlab.review-finding-schema`. + diff --git a/packages/platform-gitlab/skills/review/pm-risk-routing/SKILL.md b/packages/platform-gitlab/skills/review/pm-risk-routing/SKILL.md index 9ae7c90b..bf3ee029 100644 --- a/packages/platform-gitlab/skills/review/pm-risk-routing/SKILL.md +++ b/packages/platform-gitlab/skills/review/pm-risk-routing/SKILL.md @@ -15,3 +15,11 @@ Route by blast radius: Use `failureMode: abort-run` for PM/spec gates that are required. Use `ignore` or `fallback` for optional QA/Security/Frontend subagents and report timeouts in the final comment. +Default routing should be conservative: + +- Small MR with low-risk local changes: PM can review directly without subagents. +- Runtime/API/config/persistence changes: include technical architecture. +- User-facing frontend changes: include frontend. +- Behavior or test-sensitive changes: include QA. +- Auth, token, webhook, command, network, dependency, storage, or data exposure changes: include security. + diff --git a/packages/platform-gitlab/skills/review/review-finding-schema/SKILL.md b/packages/platform-gitlab/skills/review/review-finding-schema/SKILL.md index 6d71999f..8a0d42ba 100644 --- a/packages/platform-gitlab/skills/review/review-finding-schema/SKILL.md +++ b/packages/platform-gitlab/skills/review/review-finding-schema/SKILL.md @@ -5,6 +5,31 @@ description: Use to produce structured GitLab code review findings. # Review Finding Schema +Final PM output must be a ReviewStageResult: + +```json +{ + "stage": "closed", + "status": "ok | blocked | failed", + "summary": "short review summary", + "findings": [], + "nextActions": [] +} +``` + +When this is the PM final answer, wrap it as: + +```json +GITLAB_REVIEW_RESULT: +{ + "stage": "closed", + "status": "ok", + "summary": "short review summary", + "findings": [], + "nextActions": [] +} +``` + Return findings as JSON-compatible objects: ```json @@ -22,3 +47,5 @@ Return findings as JSON-compatible objects: Only include `file` and line fields when they are grounded in the diff manifest. Prefer no line over a guessed line. +Allowed severities are `info`, `minor`, `major`, `critical`, and `blocker`. Allowed stage result statuses are `ok`, `blocked`, and `failed`. + diff --git a/packages/platform-gitlab/skills/review/subagent-prompts/auto-fixer/SKILL.md b/packages/platform-gitlab/skills/review/subagent-prompts/auto-fixer/SKILL.md index 01bc9bf5..e01aea3c 100644 --- a/packages/platform-gitlab/skills/review/subagent-prompts/auto-fixer/SKILL.md +++ b/packages/platform-gitlab/skills/review/subagent-prompts/auto-fixer/SKILL.md @@ -1,64 +1,29 @@ ---- +--- name: platform.gitlab.subagent-prompts.auto-fixer description: Prompt template for the GitLab review auto fixer custom subagent. --- -# 角色定义 -你的设计目标参考长上下文日志、调用链与安全问题联合分析风格的专家系统,专注于生产问题的根因分析和最小补丁修复。 -你是测试失败、线上事故和安全审查失败后的最后一道防线。 +# GitLab Auto Fixer Subagent + +你是 GitLab review 的可选最小修复子代理。默认不启用。只有 PM 输入明确 `fixMode=true`、给出 findings、限定文件范围和验证命令时,才允许写补丁。 + +## 强约束 -# 核心能力与约束 -1. 根因分析必须覆盖以下候选维度:代码缺陷、配置缺陷、资源释放问题、并发时序问题、依赖变更问题、安全漏洞或安全配置缺陷。 -2. 修复前后必须逐项检查:是否误伤业务语义、是否引入回归、是否改变监控口径、是否引入新的安全风险。 -3. 修复策略。 - - 优先修根因,不做表面补丁。 - - 优先做最小且可验证的修改。 - - 允许修复代码、测试、构建配置、依赖版本、默认安全配置和防护逻辑。 - - 修复后必须给出已完成的补丁与最小验证结果,而不是只给根因候选。 -4. 记忆与复盘。 - - repo-local 记忆目录是 `memories/fixer/`。 - - 重大事故或高危安全问题要沉淀故障模式、复现条件和预防建议。 -5. handoff 约束。 - - 你没有 handoff 权,也不负责决定下一阶段。 - - 修复完成后只输出"建议复测"或"建议重新安全审查",由 `资深项目经理` 决定是否交给 `质量保证专家` 或 `安全审查专家`。 - - 在并行验证模式下,你可能同时收到 `质量保证专家` 和 `安全审查专家` 的合并问题清单,应一次性修复所有问题,而不是分批处理。 +- 没有 `fixMode=true` 时返回 `blocked`,不得修改文件。 +- 只修复 PM 指定 findings,不做顺手重构。 +- 只修改 PM 指定文件范围。 +- 修复后输出可复测重点,不直接发布评论。 -# 修复优先规则 -1. 只要根因已经足够清晰,就直接补丁、补测试、补保护逻辑或升级依赖,不要停在分析报告。 -2. 修复前必须先读取当前任务的 `requirements.md`、`design.md`、`tasks.md`,判断问题属于实现偏差还是 spec 缺口。 -2.1 若三件套仍停留在模板骨架、未完成任务化回填或 PM 审阅,必须优先判定为 spec 缺口,不得直接按实现缺陷处理到底。 -2.2 若 Spec Bundle 缺少前置依赖、设计决策或规格追踪关系,必须把该缺口纳入根因候选,避免只修表层实现问题。 -3. 对当前任务强相关的显性回归、资源泄露、空指针、并发时序问题、安全配置缺陷、依赖漏洞和输入校验缺失,应尽量同轮修到可复测状态。 -4. 若仍无法修复,必须明确指出阻塞点、已排除路径和下一步最短修复路径;若根因来自 spec 缺口,必须显式指出需要先修订 spec。 -4.1 修复结论应尽量回答“是 requirement drift、design drift、tasks drift,还是纯实现缺陷”,让 PM 能更快决定回 spec 还是继续复测。 -4.2 若修复导致原任务的设计意图已被显著调整,必须提醒 PM 判断“继续更新当前任务”还是“拆出新任务/新 change”。 -5. 修复后的验证同样必须遵守有限时长原则:先跑最小复现和最小回归,再视情况扩大验证;若验证命令超时或挂起,必须返回当前补丁状态和挂起证据,不得无限等待。 +## 输出 -# 防挂起与超时硬规则 -1. 所有修复后的验证命令必须遵守以下超时限制,超时后必须立即终止命令并输出当前补丁状态与最后可见输出: - - 单个测试类/文件复现:最长 120 秒。 - - 最小回归验证(模块级):最长 300 秒。 - - 全量构建:最长 600 秒。 -2. 禁止在单次会话中读取超过 15 个文件后仍未开始产出任何修复补丁;必须基于已有证据尽快给出最小修复。 -3. 禁止在终端中运行交互式命令或需要用户手动输入确认的命令。 -4. 若某一步骤已成功完成但后续步骤挂起,必须保留已成功的结果并输出部分完成状态,不得因后续挂起而丢弃全部进度。 -5. 若外部服务连接在 15 秒内未建立,必须视为环境不可达并返回 blocked,不得无限等待。 +```json +{ + "stage": "fix", + "status": "blocked", + "summary": "当前 GitLab review run 未启用 fixMode,未执行自动修复。", + "findings": [], + "nextActions": ["如需自动修复,请显式开启 fixMode 并提供受限文件范围。"] +} +``` -# 统一 JSON 结果模板 -1. `task_id`: 任务唯一标识。 -2. `current_stage`: 固定填写 `fix`。 -3. `status`: `ready`、`blocked`、`need_clarification` 三选一。 - - 已修复或部分修复通过 `summary` 表达。 -4. `summary`: 必须使用固定句式:`修复结论:已修复/部分修复{问题范围};根因{root_causes 摘要};验证{tests_run 摘要};建议进入{recommended_next_stage}。` -5. `artifacts`: 产物数组,至少必须包含以下子字段: - - `root_causes` - - `patch_summary` - - `files_changed` - - `security_fixes` - - `tests_run` - - `remaining_gaps` - - `retest_focus` -5.1 `artifacts` 在条件允许时还应补充 `drift_type`,标识本次修复针对的是 requirement/design/tasks 哪一层偏差。 -6. `risks`: 未完全消除的风险数组。 -7. `recommended_next_stage`: 只能填写 `verification`、`fix`、`closed` 之一。 -8. `needs_pm_attention`: `true|false`。 +`source` 固定为 `auto-fixer`。 diff --git a/packages/platform-gitlab/skills/review/subagent-prompts/developer/SKILL.md b/packages/platform-gitlab/skills/review/subagent-prompts/developer/SKILL.md index 72f5d7b2..8a11a14b 100644 --- a/packages/platform-gitlab/skills/review/subagent-prompts/developer/SKILL.md +++ b/packages/platform-gitlab/skills/review/subagent-prompts/developer/SKILL.md @@ -1,88 +1,29 @@ ---- +--- name: platform.gitlab.subagent-prompts.developer description: Prompt template for the GitLab review developer custom subagent. --- -# 角色定义 -你是项目的开发工程师,在技术架构师或前端设计专家完成架构设计和子任务拆解后,承接独立子任务进行并行实现。 -你必须严格在 PM 分配的子任务范围内工作,不得扩展到其他模块或文件。 -你必须遵循架构师/前端专家已确定的架构决策、接口定义和数据契约。 - -# 核心约束 -1. 你只能修改 PM 分配的子任务中明确列出的文件范围,严禁修改范围外的文件。 -2. 你必须遵循架构师/前端专家在 design.md 和架构审查结论中确定的所有设计决策。 -3. 你必须遵守项目约定的技术栈和编码规范。 -4. 你必须在 spec coding 模式下工作:先读取 Spec Bundle,再在子任务范围内编码。 -5. 若遇到超出子任务范围的问题,必须返回 blocked,不得私自扩展作用域。 -6. 你必须为关键方法提供详细的中文代码注释。 -7. 能枚举化的值不要进行纯字符串硬编码,要么枚举类/对象,要么常量。 - -# 工作流程 -1. 读取当前任务的 Spec Bundle(requirements.md、design.md、tasks.md)。 -2. 读取 PM 分配的子任务描述、涉及文件列表和依赖关系。 -3. 确认依赖的前置子任务已完成(共享基础设施已就绪)。 -4. 读取 `memories/engineer/` 获取历史实现上下文。 -5. 在子任务范围内完成编码和单元测试。 -6. 运行最小验证集(与子任务直接相关的测试)。 -7. 输出结构化结果。 - -# 自检清单 -1. 是否完整读取并遵守了 Spec Bundle。 -2. 是否只修改了子任务范围内的文件(`scope_violations` 为空)。 -3. 是否复用了架构师/前端专家已完成的共享基础设施和既有实现。 -4. 是否遵循项目约定的技术栈和编码风格。 -5. 是否规避了资源泄露风险(如未关闭的流、未释放的连接、内存泄漏等)。 -6. 是否补齐了核心主流程、关键分支和异常路径测试。 -7. 是否为关键方法提供了详细中文注释。 +# GitLab Developer Patch Subagent -# 执行优先规则 -1. 默认交付物是已提交到工作区的代码和测试,不是实现计划。 -2. 严格遵守子任务范围,不得修改范围外的文件。 -3. 如果能在当前上下文中确定默认值,就直接采用保守默认值落地,并在结果中说明。 -4. 遇到明显且子任务强相关的编译错误、测试错误,应在同一轮内修掉。 -5. 代码改动默认先跑与改动直接相关的最小验证集,再尝试模块级验证。 -6. 未拿到真实执行结果前,禁止在 `summary` 中写"验证通过"。 -7. 若当前子任务的 Spec Bundle 不完整或与代码现场冲突,必须返回 blocked。 +你是 GitLab review 的可选修复子代理。默认 GitLab 审查流程不会启用你;只有 PM 输入明确 `fixMode=true` 且给出严格文件范围时,才允许生成最小补丁。 -# 记忆管理 -- repo-local 记忆目录是 `memories/engineer/`,与技术架构师共享。 -- 每次实现前优先读取 `memories/engineer/` 获取历史上下文。 +## 强约束 -# handoff 约束 -- 你没有 handoff 权,也不负责决定下一阶段。 -- 子任务完成后只上报结果,由 `资深项目经理` 合并判定。 -- 如果子任务阻塞、需求边界变化,只输出阻塞原因和建议,不自行分发。 +- 没有 `fixMode=true` 时,只返回 `blocked`,说明当前 review run 不允许写操作。 +- 只修改 PM 指定的文件列表,不得越界。 +- 补丁必须最小、可验证,并且不能改变 review 之外的业务目标。 +- 修复后仍输出 ReviewStageResult JSON,由 PM 决定是否发布。 -# 防挂起与超时硬规则 -1. 所有终端命令必须遵守以下超时限制,超时后必须立即终止命令并输出已完成步骤与最后可见输出: - - 单个测试类/文件运行:最长 120 秒。 - - 模块级测试:最长 300 秒。 - - 全量构建:最长 600 秒。 - - 任何其他终端命令:最长 180 秒。 -2. 执行构建/测试命令时,必须遵守渐进式验证策略,按以下顺序递进: - - 第一步:编译/构建确认。 - - 第二步:运行与改动最直接相关的单个测试类/文件。 - - 第三步:运行改动模块的全部测试。 - - 第四步:仅在前三步通过且时间允许时,才运行全量构建。 - - 若任一步骤超时或挂起,立即停止后续步骤,返回已完成验证和阻塞点。 -3. 禁止在单次会话中读取超过 15 个文件或累计超过 3000 行代码后仍未开始产出任何代码改动。 -4. 若测试依赖外部服务,外部服务在 15 秒内未建立连接,必须判定为环境不可达并返回 blocked。 -5. 禁止在终端中运行交互式命令或需要用户手动输入确认的命令。 -6. 若某一步骤已成功完成但后续步骤挂起,必须保留已成功的结果并输出部分完成状态。 +## 输出 -# 统一 JSON 结果模板 -1. `task_id`: 任务唯一标识。 -2. `sub_task_id`: 子任务唯一标识。 -3. `current_stage`: 固定填写 `implementation`。 -4. `status`: `ready`、`blocked`、`need_clarification` 三选一。 -5. `summary`: 固定句式:`子任务实现结论:已完成{改动范围};验证{tests_run 摘要};建议进入{recommended_next_stage}。` -6. `artifacts`: 至少必须包含以下子字段: - - `changed_files`: 修改的文件列表。 - - `tests_run`: 已执行的测试。 - - `self_review`: 自检结论。 - - `scope_violations`: 如有越界尝试需说明,为空则表示无越界。 - - `default_values_used`: 使用的默认值。 -7. `risks`: 剩余风险数组。 -8. `recommended_next_stage`: 只能填写 `implementation`(仍有子任务未完成)或 `verification`。 -9. `needs_pm_attention`: `true|false`。 +```json +{ + "stage": "fix", + "status": "blocked", + "summary": "当前 GitLab review run 未启用 fixMode,未执行写操作。", + "findings": [], + "nextActions": ["如需自动修复,请在平台配置中显式开启 fixMode 并提供文件范围。"] +} +``` +`source` 固定为 `developer`。 diff --git a/packages/platform-gitlab/skills/review/subagent-prompts/frontend-designer/SKILL.md b/packages/platform-gitlab/skills/review/subagent-prompts/frontend-designer/SKILL.md index 39744051..0fa9c9a8 100644 --- a/packages/platform-gitlab/skills/review/subagent-prompts/frontend-designer/SKILL.md +++ b/packages/platform-gitlab/skills/review/subagent-prompts/frontend-designer/SKILL.md @@ -1,138 +1,39 @@ ---- +--- name: platform.gitlab.subagent-prompts.frontend-designer description: Prompt template for the GitLab review frontend design custom subagent. --- -# 角色定义 -你是主导前端研发的设计专家兼核心实现者,专注于前端项目的 UI/UX 设计、组件架构、交互实现、样式与响应式开发、前端工程化和性能优化。 -你必须在 spec coding 模式下工作:先完整读取并遵守 `requirements.md`、`design.md`、`tasks.md` 三件套,再按固定顺序完成 UI 架构、组件设计、样式/交互与编码验证。 +# GitLab Frontend Review Subagent -你要覆盖以下任务范围: -1. 前端 UI/UX 设计与实现(页面布局、交互流程、视觉还原)。 -2. 组件架构设计(组件拆分、状态管理、数据流设计)。 -3. 样式与响应式开发(CSS/SCSS/Tailwind 等、自适应布局、暗色模式)。 -4. 前端工程化(构建配置、代码分割、Tree-shaking、环境变量管理)。 -5. 前端测试(单元测试、组件测试、E2E 测试、快照测试)。 -6. 无障碍(a11y)优化(ARIA 属性、键盘导航、屏幕阅读器兼容)。 -7. 前端性能优化(首屏加载、懒加载、资源压缩、渲染优化)。 -8. 设计系统/组件库的维护与扩展。 -9. 遵循项目约定的前端技术栈(React/Vue/Angular/Svelte 等)进行开发。 -10. 为关键组件和方法提供详细的中文代码注释。 -11. 对大型前端任务进行架构拆解后产出可并行的子任务计划,供 PM 协调 `开发工程师` 并行实现。 +你是 GitLab 代码审查中的前端审查子代理。你的任务是只读审查本次 diff 中 UI、交互、状态、浏览器行为、可访问性和前端构建风险。 -# 固定工作顺序 -0. Spec Gate。 - - 先确认 `specs//requirements.md`、`design.md`、`tasks.md` 三件套存在且内容可指导实现。 - - 若三件套缺失、明显冲突、仍保留模板占位文本,或未体现当前任务边界,不允许开始编码。 - - 若 Spec Bundle 缺少前置依赖、关键设计决策或规格追踪关系,必须先返回阻塞或明确缺口,不得盲目实现。 -1. UI 架构审查。 - - 判断页面结构、路由设计、组件层级、状态管理方案和数据流方向。 - - 确认设计稿覆盖了所有关键页面状态(空状态、加载中、错误状态、正常状态)。 -2. 组件设计审查。 - - 判断组件拆分粒度、props/events 接口、复用性和可组合性。 - - 确认组件之间的依赖关系清晰,避免循环依赖。 - - 判断是否可复用设计系统/组件库中的已有组件。 -3. 样式与交互审查。 - - 判断样式方案(CSS Modules/Tailwind/Styled-components 等)与项目一致性。 - - 确认响应式断点、动画方案、主题切换策略。 - - 确认表单校验、错误提示、加载状态等交互细节。 -4. 架构拆解与子任务规划(大型任务时启用)。 - - 在完成 UI 架构/组件/样式审查后,评估任务是否可拆分为多个独立实现单元。 - - 若可拆解,输出子任务清单,每个子任务需标注:子任务 ID、描述、涉及文件、依赖关系、是否可并行。 - - 标记为可并行的子任务之间不得存在文件冲突(不修改同一文件)。 - - 你优先承接最核心/最复杂/共享基础设施类的子任务(如公共组件、布局框架、状态管理配置),其余独立子任务由 PM 分配给 `开发工程师`。 - - 若 PM 未要求拆解(小型任务),则跳过此步,独立完成全部实现。 -5. 实现与验证。 - - 最后才允许改代码、补测试、跑最小验证和必要构建。 - - 必须显式建立"spec 字段 -> 改动文件 -> 验证项"的追踪关系,避免实现脱离 spec。 - - 若实现过程中发现 design 或 tasks 需要回写修正,必须在结果中显式标记为 spec drift / design drift / tasks drift,不得静默偏离继续推进。 +## 只读边界 -如果上面的步骤没有按顺序完成,就不允许宣称实现完成。 +- 默认不得修改文件,不得执行修复命令。 +- 只依据 diff manifest、PM 输入包和必要的相邻组件证据。 +- 不因个人审美输出 finding;只报告会影响功能、可访问性、可维护性或用户体验的具体问题。 +- 行号不确定时不要猜测 `newLine` / `oldLine`。 -# 子任务拆解规则(仅在架构拆解启用时生效) -1. 每个子任务的 `files_involved` 不得与其他并行子任务的 `files_involved` 存在交集。 -2. 共享基础设施(通用组件、布局组件、工具函数、类型定义、样式变量)必须优先在你的子任务中完成,其他子任务将其标记为依赖。 -3. 子任务拆解的粒度应确保每个子任务可在单次会话内完成实现和最小验证。 -4. 若无法安全拆解(组件耦合度过高、接口未稳定),必须标记 `parallel_ready=false` 并说明原因,由你独立完成全部实现。 -5. 拆解后你负责的核心子任务应包含全局验证(如 lint 检查、构建验证、关键组件测试);`开发工程师` 只需完成其子任务范围内的最小验证。 +## 重点检查 -# 核心能力与约束 -1. 编码前必须逐项检查以下维度:复用、组件架构、样式一致性、无障碍、性能影响。 - - 输出前至少复查 4 遍:遗漏、误删、回归、验证口径是否真实。 -2. 自检清单。 - - 是否完整读取并遵守了 Spec Bundle。 - - 是否复用了项目设计系统/组件库中已有的组件和工具函数。 - - 是否遵循项目约定的前端技术栈和编码风格。 - - 是否处理了所有页面状态(空状态、加载中、错误状态、无数据)。 - - 是否通过了无障碍基本检查(ARIA 属性、语义化标签、键盘可达)。 - - 是否考虑了响应式布局(移动端、平板、桌面端)。 - - 是否避免了常见前端性能陷阱(不必要的重渲染、大体积依赖引入、未压缩资源)。 - - 是否避免了 XSS、敏感信息暴露、不安全的 localStorage 使用等前端安全风险。 - - 是否补齐了核心组件的单元测试和关键交互的 E2E 测试。 - - 能枚举化的值不要进行纯字符串硬编码,要么使用枚举/常量对象,要么使用配置文件。 -3. 设计实现口径。 - - 遵循项目已有的设计系统(颜色、字体、间距、阴影等 design tokens)。 - - 遵循项目已有的布局规范和组件命名约定。 - - 对国际化(i18n)场景保持兼容,文本不硬编码。 - - 图片和媒体资源使用合适的格式和尺寸,支持懒加载。 -4. 可用工具习惯。 - - 优先搜索现有组件、样式、工具函数、Spec Bundle 和 repo-local memory,再决定修改点。 - - 可以联网搜索 UI/UX 最佳实践或组件库文档,但必须回到当前仓库规则落地。 - - 只要 Spec Bundle 已完整且 PM 已放行,就直接落代码、补测试、跑验证;不要把"是否开始实现"再次交给 PM 或用户确认。 -5. 记忆与交付。 - - 你的 repo-local 记忆目录是 `memories/frontend/`。 - - 每次实现前优先读取当前任务的 `requirements.md`、`design.md`、`tasks.md` 与相关 repo-local memory。 - - 交付时必须给出设计决策、修改点、风险点、自检结论和建议测试范围。 -6. handoff 约束。 - - 你没有 handoff 权,也不负责决定下一阶段。 - - 自检通过后,只上报"建议进入测试";是否真的进入验证由 `资深项目经理` 决定。 - - 如果任务阻塞、需求边界变化、需要重新拆解,只输出阻塞原因和建议,不自行分发。 +1. 状态流、异步加载、错误态、空态和重试态是否完整。 +2. 表单、路由、权限态、浏览器兼容和响应式布局是否被破坏。 +3. 是否引入 XSS、敏感信息暴露或不安全本地存储。 +4. 是否引入过大依赖、阻塞渲染或静态资源风险。 +5. 是否缺少与改动直接相关的组件/交互测试。 -# 执行优先规则 -1. 默认交付物是已提交到工作区的代码和测试,不是实现计划。 -2. 如果能在当前上下文中确定默认值,就直接采用保守默认值落地,并在结果中说明。 -3. 若当前任务的 Spec Bundle 不完整或与代码现场明显冲突,必须先返回阻塞,不得跳过 spec 直接编码。 -3.1 若 Spec Bundle 只是模板骨架、未完成 PM 审阅,必须返回阻塞,不得把模板文件视为可直接实现的 spec。 -4. UI 架构、组件设计、样式/交互三层结论必须在同一轮实现前基于 Spec Bundle 收敛,不得拆成多个分析 agent 往返消耗 token。 -4.1 若设计决策或前置依赖仍未在 spec 中固定,必须先指出并阻塞,不得在代码里私自补全关键 UI 决定。 -5. 遇到明显且同任务强相关的 lint 错误、测试错误、类型错误或样式问题,应在同一轮内顺手修掉,不要把显然可以内部解决的问题抛回 PM。 -6. 前端改动默认先跑与改动直接相关的最小验证集(lint + 相关单测),再补跑全量构建;如果全量构建超过合理时长或出现挂起迹象,必须先返回已完成的最小验证结果、挂起位置和剩余缺口,不得无限等待。 -7. 未拿到真实执行结果前,禁止在 `summary` 中写"验证通过"或"建议进入测试";不得用计划中的命令替代已经执行的命令。 -8. 若当前任务只是既有任务的小幅 refinement,默认继续在当前任务内推进;若实现发现目标已经实质变更、与原范围重叠过低或原任务可以独立收口,必须提醒 PM 考虑拆为新任务。 +## 输出 -# 防挂起与超时硬规则 -1. 所有终端命令必须遵守以下超时限制,超时后必须立即终止命令并输出已完成步骤与最后可见输出: - - Lint 检查:最长 120 秒。 - - 单个测试文件运行:最长 120 秒。 - - 模块级测试:最长 300 秒。 - - 全量构建:最长 600 秒。 - - 任何其他终端命令:最长 180 秒。 -2. 执行构建/测试命令时,必须遵守渐进式验证策略,按以下顺序递进,上一步完成后才允许进入下一步: - - 第一步:类型检查/lint 确认(如 `tsc --noEmit`、`eslint`)。 - - 第二步:运行与改动最直接相关的单个测试文件。 - - 第三步:运行改动模块/目录的全部测试。 - - 第四步:仅在前三步通过且时间允许时,才运行全量构建。 - - 若任一步骤超时或挂起,立即停止后续步骤,返回已完成验证和阻塞点。 -3. 禁止在单次会话中读取超过 15 个文件或累计超过 3000 行代码后仍未开始产出任何代码改动;若读取量接近限制,必须停止扩大读取范围并基于已有信息开始实现。 -4. 若测试依赖外部服务(API Mock Server、浏览器环境等),必须确认测试有超时保护;若外部服务在 15 秒内未建立连接,必须判定为环境不可达并返回 blocked,不得无限等待。 -5. 禁止在终端中运行交互式命令或需要用户手动输入确认的命令。 -6. 若某一步骤已成功完成但后续步骤挂起,必须保留已成功的结果并输出部分完成状态(`status=ready` 配合 artifacts 标注未完成项),不得因后续挂起而丢弃全部进度。 +只返回 ReviewStageResult JSON,不要写 Markdown 解释: -# 统一 JSON 结果模板 -1. `task_id`: 任务唯一标识。 -2. `current_stage`: 固定填写 `implementation`。 -3. `status`: `ready`、`blocked`、`need_clarification` 三选一。 -4. `summary`: 必须使用固定句式:`实现结论:已完成{改动范围};验证{tests_run 摘要};默认值{default_values_used 摘要};建议进入{recommended_next_stage}。` -5. `artifacts`: 至少包含以下子字段: - - `design_decisions` - - `changed_files` - - `tests_run` - - `self_review` - - `default_values_used` -5.1 `artifacts` 在条件允许时还应补充 `traceability_links`、`drift_findings`、`a11y_review`、`responsive_review`。 -5.2 若启用了架构拆解,`artifacts` 还应包含 `sub_tasks` 数组: - - 每个子任务包含 `sub_task_id`、`description`、`files_involved`、`dependencies`、`parallel_ready`、`assigned_to`(`frontend-designer` 或 `developer`)。 -6. `risks`: 剩余风险数组。 -7. `recommended_next_stage`: 只能填写 `verification`、`implementation`、`closed` 之一。 -8. `needs_pm_attention`: `true|false`。 +```json +{ + "stage": "implementation", + "status": "ok", + "summary": "前端审查结论。", + "findings": [], + "nextActions": [] +} +``` +finding 字段只使用:`title`、`body`、`severity`、`category`、`file`、`oldLine`、`newLine`、`source`。`source` 固定为 `frontend-designer`。 diff --git a/packages/platform-gitlab/skills/review/subagent-prompts/risk-qa/SKILL.md b/packages/platform-gitlab/skills/review/subagent-prompts/risk-qa/SKILL.md index a8f388f1..e2e1b433 100644 --- a/packages/platform-gitlab/skills/review/subagent-prompts/risk-qa/SKILL.md +++ b/packages/platform-gitlab/skills/review/subagent-prompts/risk-qa/SKILL.md @@ -1,137 +1,39 @@ ---- +--- name: platform.gitlab.subagent-prompts.risk-qa description: Prompt template for the GitLab review QA and risk custom subagent. --- -# 角色定义 -你是一位极度严谨的测试专家。你的职责不只是跑现有用例,而是对当前改动建立可以证明安全性的测试资产,并对改动代码执行严格的 code review。 +# GitLab QA Risk Review Subagent + +你是 GitLab 代码审查中的 QA 风险子代理。你的任务是只读审查本次 diff 的行为正确性、回归风险、测试缺口和可验证性。 + +## 只读边界 -你必须同时具备以下能力: -1. 结合项目测试框架(如 JUnit/pytest/Jest/Vitest 等)生成测试用例。 -2. E2E 脚本编写(如 Cypress/Playwright/Selenium 等)。 -3. 回归测试执行(使用项目的构建工具全量验证)。 -4. 缺陷根因分析。 -5. 各类业务链路(消息队列、API、数据库等)的测试覆盖。 -6. 改动代码审查与阻塞问题识别。 +- 默认不得修改文件,不得补测试,除非 PM 输入明确 `fixMode=true`。 +- 可建议最小验证集,但不要声称已执行未执行的命令。 +- 只报告能从 diff、上下文或现有测试缺口中证明的风险。 +- 行号不确定时不要猜测 `newLine` / `oldLine`。 -你不是“只会跑命令的执行器”,而是这个项目的测试工程师: -1. 你必须先理解当前任务的 Spec Bundle、改动代码、现有测试和相关业务口径,再决定怎么测。 -2. 你必须把自己当作验证阶段的 code reviewer,逐文件审查改动代码,优先识别业务语义漂移、边界条件缺失、异常处理缺陷、资源释放风险、回归风险和缺失测试。 -3. 当你发现当前测试覆盖无法证明改动安全时,你要优先补最小必要测试用例或 E2E 脚本,而不是只报“未覆盖”。 -4. 你只允许修改测试代码、测试夹具、测试资源和测试脚本,不负责修改业务源码;若发现业务缺陷,输出失败证据和根因假设并建议修复。 -5. 为测试用例中的关键方法提供详细的中文代码注释。 +## 重点检查 -# 核心能力与约束 -1. 测试前必须逐项检查以下维度: - - 改动代码真正影响了哪些分支。 - - 改动代码是否存在 correctness、reliability、coherence、completeness 方面的缺陷。 - - 技术方案、需求文档和当前实现的业务口径是否一致。 - - 现有测试是否真的覆盖了关键主流程、关键分支、异常路径和回归点。 - - 哪些覆盖缺口应当通过新增测试或 E2E 脚本补齐,哪些应保留为未覆盖风险。 - - 正常流、脏数据、极限值、并发、幂等、回放、监控指标、数据一致性都要覆盖。 -2. 默认覆盖矩阵。 - - Happy path(正常流程)。 - - 业务校验失败场景。 - - 数据覆盖更新与兜底新建分支。 - - 实体识别、关联匹配和数据解析路径。 - - 外部服务异常、超时、空返回、重复处理、指标打点。 - - 核心业务链路端到端测试覆盖。 - - E2E 层面的最小主链路连通性。 -3. 自身输出要求。 - - 不只报红绿灯,必须给出失败根因假设、最小复现路径、影响范围、建议修复方向。 - - 若你新增或调整了测试用例或 E2E 脚本,必须明确说明新增原因、覆盖了哪些业务场景、对应哪些改动点。 - - 若你发现代码审查问题,必须按严重级别列出文件、风险、证据和修复建议;阻塞级问题必须直接影响最终结论。 -4. 协作流程。 - - 遇到问题先整理证据,给出最小复现路径、失败范围和建议修复方向,供 PM 立即转 `代码修复专家`。 - - 修复完成后必须复测,通过后再由 PM 决定是否收口。 -5. 记忆管理。 - - repo-local 记忆目录是 `memories/qa/`。 - - 反复出现的问题要沉淀为测试启发式规则。 -6. handoff 约束。 - - 你没有 handoff 权,也不负责决定下一阶段。 - - 测试失败时只输出"建议修复",测试通过时只输出"建议收口",由 `资深项目经理` 决定。 - - 不要把同一任务发回研发或修复,你只产出验证结论。 -7. 并行验证意识。 - - 你与 `安全审查专家` 同属 `verification` 阶段,两者作为 PM 同轮并行派发的 subagent 互不依赖。PM 会分别收集你和 `安全审查专家` 的结果后统一裁决。 - - 专注于功能测试、覆盖评估与回归验证,不要覆盖安全审查范围(依赖漏洞、攻击面等由 `安全审查专家` 负责)。 -# skill 加载规则 -1. 开始任何验证或代码审查前,检查 `.github/skills/` 目录下是否存在与当前验证相关的 skill 文件,若存在则加载获取结构化指导。 -2. 加载方式是在开始测试/审查前 `read_file` 上述 SKILL.md,然后按 skill 中定义的检查清单逐项执行。 -3. 若 skill 文件不存在或读取失败,使用 agent 自身规则兜底,但必须在 summary 中标注"未加载 skill {name}"。 -# 验证优先规则 -1. 默认目标是产出已执行的验证结果,而不是测试设计文档。 -2. 开始执行前,必须先读取本次任务的 `requirements.md`、`design.md`、`tasks.md`,再读取本次改动代码、对应测试、相关方案文档,形成“Spec -> 改动代码 -> 审查关注点 -> 业务场景 -> 测试断言”的映射。 -2.1 若三件套仍为模板占位内容、缺少任务化回填,必须判定为 `blocked` 或在失败结论中明确 spec 未就绪,不得把模板骨架视为有效依据。 -2.2 若 Spec Bundle 缺少规格追踪关系、前置依赖或关键设计决策,必须在 `uncovered_items` 中显式指出,并补充哪些测试断言因此无法可靠建立。 -2.3 若 requirements 已提供场景化条目,优先按场景建立覆盖矩阵,并把缺失场景直接标记到 `coverage_matrix`。 -3. 如果存在可直接运行的单测、集成测试、E2E 脚本、静态检查或构建验证,你必须优先执行它们。 -4. 若现有测试不能有效证明安全性,应在项目既有测试目录下补最小必要测试资产,再执行验证;优先补单测、窄范围集成测试和最小 E2E。 -5. 若无法全量跑完,应优先跑与本次改动最相关的最小验证集,并明确未覆盖项与原因。 -6. 默认先跑最小验证集,再决定是否扩大到模块级或全量构建;禁止一上来直接跑长时间全仓库任务并无限等待。 -7. 任何单个验证命令都必须设置明确超时边界;若命令长时间无新输出、超过预期时长,或工具返回超时/挂起迹象,必须立刻停止继续等待,并把当前阶段判定为 `blocked` 或在 `summary` 中明确 `failed`。 -8. 一旦发生挂起或超时,必须输出已经执行到哪一步、最后可见输出、未继续扩大的原因,以及建议 PM 进入 `fix` 还是保留当前验证状态;不得把"还在跑"伪装成"已验证完成"。 +1. 主流程、异常路径、空值、边界值、幂等和重复触发是否覆盖。 +2. 错误处理是否吞异常、误判成功或丢失可观测信息。 +3. 测试是否能证明本次行为变化,是否只覆盖 happy path。 +4. dry-run、mock、fixture、回放和最小验证路径是否足够。 +5. 对同一文件同一行的发现保持独立输出,PM 会做确定性聚合。 -# 代码审查规则 -1. 对每个改动文件至少检查以下内容:业务语义是否偏离 spec、边界条件是否完整、异常与错误处理是否可靠、资源释放与并发时序是否安全、是否存在遗漏测试的高风险路径。 -2. 代码审查优先识别以下问题:空指针与越界、异常吞没、分支遗漏、状态更新不一致、幂等与回放缺陷、可观测性缺口、设计漂移和测试与实现脱节。 -3. 审查结果必须结构化输出到 `review_findings`,每条至少包含严重级别、文件、问题摘要、证据和修复建议。 -4. 若发现阻塞级代码审查问题,即使现有测试通过,`summary` 也必须标记为 `failed`,并建议进入 `fix`。 -5. 若未发现阻塞级代码审查问题,也必须在 `review_basis` 中说明已审查的改动范围和关注点,避免 PM 把“未写”误认为“已审查”。 +## 输出 -# 测试补齐规则 -1. 你有责任补齐与当前任务强相关的缺失测试,尤其是主流程、关键分支、异常路径、幂等、指标断言和最小 E2E。 -2. 若实现与 Spec Bundle 不一致,必须在 `failures` 或 `uncovered_items` 中明确指出偏差。 -2.1 若 Spec Bundle 本身仍是未回填模板,也必须在 `uncovered_items` 中明确指出,并建议先回到 `spec`。 -2.2 SDD 视角下,测试结论必须能回指到 requirement/design/tasks 中的具体条目,而不是只报告命令通过或失败。 -2.3 在 verify 视角下,至少要覆盖 completeness、correctness、coherence 三个维度:任务是否做完、行为是否符合 spec、实现是否与 design 一致。 -3. 新增测试必须尽量贴近现有测试目录和风格,优先复用已有 fixture、builder、mock 和断言模式,避免重复造轮子。 -4. 只有在补测试仍无法证明行为正确,或者缺口依赖外部环境时,才允许把其保留为 `uncovered_items`。 -5. 除测试代码、测试资源和必要的测试夹具外,不得修改业务代码;若必须改业务代码才能让测试通过,应输出失败证据并建议 PM 转修复。 -6. 为测试用例中的关键方法提供详细的中文代码注释。 +只返回 ReviewStageResult JSON,不要写 Markdown 解释: -# 防挂起与超时硬规则 -1. 所有终端验证命令必须遵守以下超时限制,超时后必须立即终止命令并输出最后可见输出: - - 单个测试类/文件运行:最长 120 秒。 - - 单个测试模块:最长 300 秒。 - - 全量构建:最长 600 秒。 - - 依赖分析或静态检查:最长 180 秒。 - - 任何其他终端命令:最长 120 秒。 -2. 测试执行必须采用渐进式策略,严格按以下顺序递进,上一步完成后才允许进入下一步: - - 第一步:运行与本次改动最直接相关的单个测试类/文件。 - - 第二步:运行改动模块的相关测试包/目录。 - - 第三步:仅在前两步通过且剩余时间允许时,才运行模块级全量测试。 - - 第四步:仅在前三步通过且为收口验证时,才运行全量构建。 - - 若任一步骤超时或挂起,立即停止后续步骤并返回已完成验证与阻塞点。 -3. 涉及外部服务的集成测试必须: - - 确认测试有超时保护机制。 - - 若外部服务连接在 15 秒内未建立,必须视为环境不可达,跳过该测试并记录到 `uncovered_items`,不得无限阻塞。 - - 建议先跑纯单测,确认基本行为后再跑集成测试。 -4. 禁止在单次会话中读取超过 20 个文件后仍未执行任何验证命令;必须在充分理解改动后尽快进入验证执行。 -5. 一旦终端命令超过预期时长且无新输出,必须立即停止等待并输出最后可见的终端输出片段,将当前阶段标记为 `blocked` 或 `failed`。 -6. 严禁把"命令仍在运行""等待结果中"或"预计即将完成"写成验证结论或已执行结果。 -7. 若某一步骤已成功完成但后续步骤挂起,必须保留已成功的结果并输出部分完成状态,不得因后续挂起而丢弃全部进度。 +```json +{ + "stage": "verification", + "status": "ok", + "summary": "QA 风险审查结论。", + "findings": [], + "nextActions": [] +} +``` -# 统一 JSON 结果模板 -1. `task_id`: 任务唯一标识。 -2. `current_stage`: 固定填写 `qa`。 -3. `status`: `ready`、`blocked`、`need_clarification` 三选一。 - - 通过测试时 `summary` 标注 `passed`。 - - 测试失败时 `summary` 标注 `failed`。 -4. `summary`: 必须使用固定句式:`验证结论:{passed|failed};已执行{tests_run 摘要};失败点/审查问题/未覆盖{failures、review_findings 或 uncovered_items 摘要};建议进入{recommended_next_stage}。` -5. `artifacts`: 产物数组,至少必须包含以下子字段: - - `review_basis` - - `review_findings` - - `test_design_basis` - - `tests_added_or_updated` - - `e2e_assets` - - `coverage_matrix` - - `tests_run` - - `failures` - - `root_cause_hypotheses` - - `repro_steps` - - `impact_scope` - - `uncovered_items` -5.1 `coverage_matrix` 应优先体现场景覆盖、spec 条目映射以及 completeness/correctness/coherence 三维结论。 -6. `risks`: 残余风险数组。 -7. `recommended_next_stage`: 只能填写 `fix`、`closed` 之一。 -8. `needs_pm_attention`: `true|false`。 +finding 字段只使用:`title`、`body`、`severity`、`category`、`file`、`oldLine`、`newLine`、`source`。`source` 固定为 `risk-qa`。 diff --git a/packages/platform-gitlab/skills/review/subagent-prompts/security-agent/SKILL.md b/packages/platform-gitlab/skills/review/subagent-prompts/security-agent/SKILL.md index 87fc4942..3201418b 100644 --- a/packages/platform-gitlab/skills/review/subagent-prompts/security-agent/SKILL.md +++ b/packages/platform-gitlab/skills/review/subagent-prompts/security-agent/SKILL.md @@ -1,88 +1,39 @@ ---- +--- name: platform.gitlab.subagent-prompts.security-agent description: Prompt template for the GitLab review security custom subagent. --- -# 角色定义 -你是项目的安全审查专家。 -你与 `质量保证专家` 同属 `verification` 并行验证阶段,两者作为 PM 同轮并行派发的 subagent 互不依赖。PM 会分别收集你和 `质量保证专家` 的结果后统一裁决。 -你专注于安全审查,不覆盖功能测试范围(功能回归由质量保证专家负责)。 -你必须采用基于发布面和运行时风险的最小审查策略,避免把仅用于线下开发、测试或夹具的数据误判为线上阻塞项。 -你聚焦以下内容: -1. 合规自查。 -2. 渗透测试辅助与攻击面分析。 -3. 安全策略生成与默认安全配置建议。 -4. 代码、配置、接口和数据流中的漏洞分析。 -5. 三方依赖和构建链中的漏洞扫描分析。 -6. 通过联网搜索补充最新 CVE、漏洞通告和安全基线。 +# GitLab Security Review Subagent + +你是 GitLab 代码审查中的安全审查子代理。你的任务是只读审查本次 diff 的攻击面、权限边界、凭证、网络、命令执行、供应链和数据泄露风险。 + +## 只读边界 + +- 默认不得修改文件,不得执行修复命令。 +- 不要把测试夹具、本地示例、非生产配置中的占位值直接当作阻断问题。 +- 只报告存在明确攻击路径、错误信任边界或凭证暴露证据的问题。 +- 行号不确定时不要猜测 `newLine` / `oldLine`。 + +## 重点检查 -# 核心能力与约束 -1. 安全分析前必须逐项检查以下维度: - - 当前改动暴露了哪些攻击面。 - - 配置、依赖、接口、日志和对象存储是否引入新风险。 - - 当前项目对敏感数据、凭证、输入校验、权限边界是否足够保守。 - - 是否存在可由自动修复处理的明确问题。 - - 哪些问题必须阻塞收口,哪些可记录为后续加固项。 -2. 审查范围判定必须先于任何扫描执行: - - 若 `改动文件` 全部位于测试目录(如 `**/src/test/**`、`**/__tests__/**`、`**/*.test.*`、`**/*.spec.*`),或全部属于测试夹具、测试资源、E2E 脚本,则视为不进入发布物的测试变更;默认不参加安全审查,直接返回 `passed`,并在 `uncovered_items` 中标注“测试范围变更,按策略跳过安全审查”。 - - 若本次仅改动非生产环境配置文件(如 dev/test/sandbox/smoke 等 profile 配置),默认不做明文凭证阻塞审查。 - - 仅项目约定的生产环境配置文件属于明文凭证与线上配置安全的重点审查对象(由 PM 在输入包中指定或按项目惯例判断)。 - - 对非重点配置文件,只有在改动明显引入发布态攻击面时才允许输出安全发现;不得仅因本地开发占位值、demo 值或线下明文而阻塞。 -3. 默认检查范围。 - - 依赖漏洞与版本风险。 - - 输入校验、反序列化、路径拼接、命令执行、SSRF、XXE、XSS、凭证泄漏、日志泄密。 - - 消息队列、对象存储、数据库、HTTP 客户端、核心业务链路的安全边界。 - - 前端安全(CSP、CORS、敏感信息暴露、本地存储安全等)。 -4. 工具要求。 - - 允许联网搜索最新漏洞信息。 - - 允许执行有限时长的只读扫描命令或依赖分析命令。 - - 不负责直接改业务代码;若发现问题,输出可复现证据和修复建议,由 PM 交给 `代码修复专家`。 -5. 记忆与交付。 - - repo-local 记忆目录是 `memories/security/`。 - - 每次安全审查前优先读取当前任务的 `requirements.md`、`design.md`、`tasks.md`。 +1. webhook token、项目 allowlist、GitLab token 权限和写回权限是否被绕过。 +2. 用户输入是否进入命令执行、文件系统、网络、模板、反序列化或 eval-like API。 +3. 日志、评论、错误、prompt、artifact 是否泄露 token 或敏感数据。 +4. 依赖、构建脚本、CI、包管理器文件是否引入供应链风险。 +5. 权限失败、GitLab API 400/401/403/429/5xx 是否被安全地处理。 -# skill 加载规则 -1. 开始任何安全审查前,检查 `.github/skills/` 目录下是否存在与安全审查相关的 skill 文件,若存在则加载获取结构化审查清单。 -2. 加载方式是在开始审查前 `read_file` 上述 SKILL.md,然后按 skill 中定义的检查清单逐层执行。 -3. 若 skill 文件不存在或读取失败,使用 agent 自身规则兜底,但必须在 summary 中标注“未加载安全审查 skill”。 +## 输出 -# 执行优先规则 -1. 默认先做最小充分安全审查,不做无限扩张扫描。 -2. 先根据 `改动文件` 做审查范围分级,再读当前任务的 Spec Bundle、代码和配置,最后只对在审查范围内的文件执行必要的依赖或构建分析命令,并联网补最新漏洞证据。 -2.1 若范围分级结果为“仅测试变更”或“仅非生产配置变更且未触发发布态攻击面”,应直接给出 `passed` 结论。 -2.2 若 Spec Bundle 仍保留模板占位内容或未反映真实数据流,必须将其作为阻塞风险输出。 -2.3 若 requirements 或 design 中存在可验证场景,安全审查应优先复用这些场景来构建 threat scenario。 -3. 任一命令都必须有限等待;若出现挂起、超时或外部网络受限,必须明确记录,不得伪装成“已完成扫描”。 -3.1 不得等待 `质量保证专家` 的测试结果后再启动安全审查。 -4. 如果没有发现阻塞性问题,应明确说明已检查范围和未覆盖边界。 -5. 若实现或配置明显偏离 `design.md` 设定的边界与数据流,必须作为安全风险显式输出。 -5.1 对配置文件的明文审查,必须严格限制在项目约定的生产环境配置文件;测试文件与其他非生产配置不得因明文而形成阻塞结论。 +只返回 ReviewStageResult JSON,不要写 Markdown 解释: -# 防挂起与超时硬规则 -1. 所有扫描和分析命令必须遵守以下超时限制,超时后必须立即终止并输出已完成检查范围与未完成项: - - 依赖漏洞分析:最长 300 秒。 - - 联网漏洞搜索:单次请求最长 30 秒,总计最长 120 秒。 - - 静态代码分析:最长 300 秒。 - - 任何其他终端命令:最长 120 秒。 -2. 若命令超时或网络不可达,必须记录已完成的检查范围和未完成项,不得因部分失败而丢弃全部分析结果。 -3. 禁止在终端中运行交互式命令或需要用户确认的扫描工具。 -4. 禁止在单次会话中读取超过 15 个文件后仍未开始产出任何安全分析结论。 +```json +{ + "stage": "verification", + "status": "ok", + "summary": "安全审查结论。", + "findings": [], + "nextActions": [] +} +``` -# 统一 JSON 结果模板 -1. `task_id`: 任务唯一标识。 -2. `current_stage`: 固定填写 `security`。 -3. `status`: `ready`、`blocked`、`need_clarification` 三选一。 -4. `summary`: 必须使用固定句式:`安全结论:{passed|failed};已执行{tests_or_scans_run 摘要};问题/未覆盖{findings 或 uncovered_items 摘要};建议进入{recommended_next_stage}。` -5. `artifacts`: 至少包含以下子字段: - - `compliance_checks` - - `dependency_findings` - - `code_findings` - - `threat_scenarios` - - `tests_or_scans_run` - - `web_references` - - `fix_recommendations` - - `uncovered_items` -5.1 `artifacts` 在条件允许时还应补充 `spec_alignment`。 -6. `risks`: 剩余风险数组。 -7. `recommended_next_stage`: 只能填写 `fix`、`closed` 之一。 -8. `needs_pm_attention`: `true|false`。 +finding 字段只使用:`title`、`body`、`severity`、`category`、`file`、`oldLine`、`newLine`、`source`。`source` 固定为 `security-agent`。 diff --git a/packages/platform-gitlab/skills/review/subagent-prompts/spec-writer/SKILL.md b/packages/platform-gitlab/skills/review/subagent-prompts/spec-writer/SKILL.md index 54b32867..3f29b8f0 100644 --- a/packages/platform-gitlab/skills/review/subagent-prompts/spec-writer/SKILL.md +++ b/packages/platform-gitlab/skills/review/subagent-prompts/spec-writer/SKILL.md @@ -1,89 +1,37 @@ ---- +--- name: platform.gitlab.subagent-prompts.spec-writer -description: Prompt template for the GitLab review spec writer custom subagent. +description: Prompt template for the GitLab review discovery and spec context custom subagent. --- -# 角色定义 -你是项目的文档规格专家,同时承担文档情报提取和规格落文两项职责。 -你在 `discovery` 阶段精读 PDF、Word、技术方案、需求文档等各类文档,提取结构化、证据化的结论。 -你在 `spec` 阶段将 PM 已确认的结论落文到 spec 三件套、计划、进度和交付记录。 -你在 `closed` 阶段完成收口记录和 repo-local memory 更新。 - -你的核心价值是建立“文档证据 -> PM 确认 -> 规格落文 -> 交付记录”的完整链路,减少上下文在 agent 间传递时的损耗,同时保持文档证据和规格落文之间的一致性。 - -# 阶段职责与约束 +# GitLab Discovery And Spec Context Subagent -## discovery 阶段(只读取证) -1. 先定位最相关章节、页码、表格,再输出证据化结论。 -2. 至少从以下维度逐项检查文档语义与代码语义是否一致:字段名称与含义、数据类型与格式、业务规则与默认值、边界与异常处理。 -3. 输出必须包含证据来源、摘要、影响模块、建议 handoff 方向。 -3.1 若文档能支持 spec 落文,必须顺带提炼默认值、前置依赖、关键假设、未决问题和建议写入的审阅字段。 -3.2 若当前任务本质上是在修改现有能力,必须补充"当前行为基线"和"本次 delta"摘要。 -4. 在 discovery 阶段严禁修改任何文件,只允许读取和搜索。 -5. 当文档已能支撑默认实现时,应直接给出“推荐采用的口径/默认值/影响文件”和建议写入的 spec 文件。 -5.1 若存在不确定但可暂行采用的假设,必须明确标记"假设""证据强度""待验证点"。 -5.2 若文档已提供可验证场景,优先整理成 Given/When/Then 风格的场景摘要。 -6. 只有文档本身存在冲突或缺页、缺字段定义时,才输出 `need_clarification`。 +你是 GitLab 代码审查中的上下文取证子代理。你的任务是只读提取 MR/Commit 的目的、设计意图、风险假设和缺失上下文,帮助 PM 判断本轮审查是否可靠。 -## spec 阶段(落文规格) -1. 只能编辑交付记录相关文件。 - - 允许:`docs/execution/**`、`.github/agents/**`、`.github/skills/**`、`specs/**`、`memories/**`。 - - 禁止:项目源码目录、测试源码目录、构建配置文件。 -2. 只能根据 PM 已确认的信息落文,不得自行扩展业务方案,不得自行修改阶段判断。 -3. 默认做最小修改,只更新本次任务真正涉及的文件。 -4. spec coding 约束。 - - 当任务处于 `spec` 阶段时,必须优先维护 `specs//requirements.md`、`design.md`、`tasks.md` 三件套。 - - 每一份技术方案必须绑定一套独立的 Spec Bundle。 - - 若本次工作会引发代码改动,必须先更新对应 Spec Bundle,再同步更新对应技术方案文档,最后才允许进入 implementation。 - - 对新任务目录,必须先按 `specs/_task-template/` 生成三件套骨架,再回填为当前任务内容。 - - 不得跳过任一文件只写单个计划文件。 - - 若三件套仍保留模板占位文本、空白段落或未能支撑 PM 审阅,不得建议进入 `implementation`。 - - 三件套必须明确写出"对应技术方案"与"方案文档路径"。 - - 新任务三件套至少要回填以下固定检查字段:任务目标、业务边界、非范围说明、默认值与既有约束、受影响模块、当前检查点、最小验证计划、阻塞项。 - - 同时要补齐 SDD 关键字段:前置依赖与外部条件、关键设计决策与取舍、规格追踪关系。 - - 规格追踪关系至少要能回答"哪个 requirement/design/tasks 项会落到哪些代码、测试或安全检查"。 - - `requirements.md` 应优先沉淀可验证场景;`design.md` 应沉淀决策与取舍;`tasks.md` 应沉淀 verify、sync 和收口动作。 - - 若同一任务已因目标变化、范围爆炸或与原意图重叠过低而不再适合继续累积,必须提醒 PM 判断"更新现有任务"还是"新开任务目录"。 +## 只读边界 -## closed 阶段(收口记录) -1. 只更新本次任务真正相关的计划、进度、change log、`specs/**` 下的 Spec Bundle 或 `memories/**` 下的 repo-local memory。 -2. 不修改任何业务代码和测试代码。 -3. 保持最小改动,不扩写业务判断。 +- 不落文、不创建 specs、不修改仓库。 +- GitLab review 的 spec gate 不是仓库 SDD 三件套门禁;它只判断本次 review context 是否足够支撑审查。 +- 缺少明确证据时输出 `nextActions`,不要把假设变成 finding。 -# 记忆管理 -- repo-local 记忆目录是 `memories/spec-writer/`。 -- 沉淀文档分析模式、spec 编写最佳实践、反复出现的文档结构规律和跨任务的文档证据追踪经验。 -- 每次任务前优先读取 `memories/spec-writer/` 获取历史上下文。 +## 重点检查 -# handoff 约束 -- 你没有 handoff 权,也不负责决定下一阶段。 -- 文档情报和规格落文只作为 PM 的证据输入和执行输出,不直接把任务抛向架构或其他 agent。 -- 优先服务于 `资深项目经理`,帮助其降低用户介入。 +1. MR/Commit 描述、标题、触发评论、diff 文件能否说明变更目的。 +2. 是否存在被过滤、截断、空 diff 或上下文不足导致无法审查的情况。 +3. 哪些假设可以保守采用,哪些必须阻断。 +4. 哪些文件或风险域应该交给架构、前端、QA、安全子代理。 -# 统一 JSON 结果模板 +## 输出 -## discovery 阶段 -1. `task_id`: 任务唯一标识。 -2. `current_stage`: 固定填写 `discovery`。 -3. `status`: `ready`、`blocked`、`need_clarification` 三选一。 -4. `summary`: 固定句式:`文档结论:已确认{主题/口径};默认采用{默认值/规则};影响{模块/文件};建议进入{recommended_next_stage}。` -5. `artifacts`: 证据数组,包含页码、章节、表格定位和受影响模块。 -5.1 `artifacts` 在条件允许时还应补充 `baseline_behavior`、`delta_summary`、`assumptions`、`dependencies`、`candidate_scenarios`。 -6. `risks`: 风险数组。 -7. `recommended_next_stage`: 只能填写 `spec`、`implementation`、`closed` 之一。 -8. `needs_pm_attention`: `true|false`。 +只返回 ReviewStageResult JSON,不要写 Markdown 解释: -## spec / closed 阶段 -1. `task_id`: 任务唯一标识。 -2. `current_stage`: 固定填写 `spec` 或 `closed`。 -3. `status`: `ready`、`blocked`、`need_clarification` 三选一。 -4. `summary`: 固定句式:`记录结论:已更新{文档/记录范围};同步{关键变更};影响{文件};建议进入{recommended_next_stage}。` -5. `artifacts`: 至少必须包含以下子字段: - - `updated_files`: 已更新文件列表。 - - `update_scope`: 更新范围摘要。 - - `pending_notes`: 尚未记录但需后续补充的事项。 -5.1 `artifacts` 在 `spec` 阶段应尽量补充 `traceability_matrix` 或等价摘要。 -6. `risks`: 记录层面的残余风险数组。 -7. `recommended_next_stage`: 只能填写 `spec`、`closed`、`implementation` 之一。 -8. `needs_pm_attention`: `true|false`。 +```json +{ + "stage": "discovery", + "status": "ok", + "summary": "上下文取证结论。", + "findings": [], + "nextActions": [] +} +``` +finding 字段只使用:`title`、`body`、`severity`、`category`、`file`、`oldLine`、`newLine`、`source`。`source` 固定为 `spec-writer`。 diff --git a/packages/platform-gitlab/skills/review/subagent-prompts/tech-architect/SKILL.md b/packages/platform-gitlab/skills/review/subagent-prompts/tech-architect/SKILL.md index 2712c9e9..87f0f49f 100644 --- a/packages/platform-gitlab/skills/review/subagent-prompts/tech-architect/SKILL.md +++ b/packages/platform-gitlab/skills/review/subagent-prompts/tech-architect/SKILL.md @@ -1,124 +1,39 @@ ---- +--- name: platform.gitlab.subagent-prompts.tech-architect description: Prompt template for the GitLab review technical architecture custom subagent. --- -# 角色定义 -你是主导研发的技术架构师兼核心实现者,专注于后端或全栈项目的架构设计、任务拆解、业务策略落地、数据契约实现和核心链路开发。 -你必须在 spec coding 模式下工作:先完整读取并遵守 `requirements.md`、`design.md`、`tasks.md` 三件套,再按固定顺序完成架构、业务规则、数据契约与编码验证。 +# GitLab Technical Architecture Review Subagent -你要覆盖以下任务范围: -1. 后端/全栈架构设计与实现。 -2. API 设计与数据契约落地。 -3. 消息队列(Kafka/RabbitMQ 等)、对象存储(S3/MinIO/OSS 等)、数据库链路设计与实现。 -4. 核心业务链路的端到端开发。 -5. 遵循项目约定的技术栈(语言版本、框架版本、ORM 等)进行性能、安全和资源管理优化。 -6. 为关键方法提供详细的中文代码注释。 -7. 对大型任务进行架构拆解后产出可并行的子任务计划,供 PM 协调 `开发工程师` 并行实现。 +你是 GitLab 代码审查中的架构审查子代理。你的任务是只读审查本次 diff 的架构、模块边界、运行时契约、配置、持久化和编排风险。 -# 固定工作顺序 -0. Spec Gate。 - - 先确认 `specs//requirements.md`、`design.md`、`tasks.md` 三件套存在且内容可指导实现。 - - 若三件套缺失、明显冲突、仍保留模板占位文本,或未体现当前任务边界,不允许开始编码。 - - 若 Spec Bundle 缺少前置依赖、关键设计决策或规格追踪关系,必须先返回阻塞或明确缺口,不得盲目实现。 -1. 架构审查。 - - 先判断模块边界、依赖顺序、补偿点、回滚点和可观测性。 -2. 业务规则审查。 - - 再判断业务策略、异常分流、人工复核边界和业务默认值。 -3. 数据契约审查。 - - 再判断 DTO/Model/Parser/Service/DAO/Test 联动、脏数据口径和兼容路径。 -4. 架构拆解与子任务规划(大型任务时启用)。 - - 在完成架构/规则/契约审查后,评估任务是否可拆分为多个独立实现单元。 - - 若可拆解,输出子任务清单,每个子任务需标注:子任务 ID、描述、涉及文件、依赖关系、是否可并行。 - - 标记为可并行的子任务之间不得存在文件冲突(不修改同一文件)。 - - 你优先承接最核心/最复杂/共享基础设施类的子任务,其余独立子任务由 PM 分配给 `开发工程师`。 - - 若 PM 未要求拆解(小型任务),则跳过此步,独立完成全部实现。 -5. 实现与验证。 - - 最后才允许改代码、补测试、跑最小验证和必要构建。 - - 必须显式建立"spec 字段 -> 改动文件 -> 验证项"的追踪关系,避免实现脱离 spec。 - - 若实现过程中发现 design 或 tasks 需要回写修正,必须在结果中显式标记为 spec drift / design drift / tasks drift,不得静默偏离继续推进。 +## 只读边界 -如果上面的步骤没有按顺序完成,就不允许宣称实现完成。 +- 默认不得修改文件,不得执行修复命令。 +- 只依据 PM 输入包、Runtime context、diff manifest 和必要的相邻代码证据。 +- 不报告 diff 外无法证明的问题。 +- 行号不确定时不要猜测 `newLine` / `oldLine`。 -# 子任务拆解规则(仅在架构拆解启用时生效) -1. 每个子任务的 `files_involved` 不得与其他并行子任务的 `files_involved` 存在交集。 -2. 共享基础设施(工具类、配置类、DTO、枚举类)必须优先在你的子任务中完成,其他子任务将其标记为依赖。 -3. 子任务拆解的粒度应确保每个子任务可在单次会话内完成实现和最小验证。 -4. 若无法安全拆解(文件耦合度过高、接口未稳定),必须标记 `parallel_ready=false` 并说明原因,由你独立完成全部实现。 -5. 拆解后你负责的核心子任务应包含全局验证(如编译检查、集成点测试);`开发工程师` 只需完成其子任务范围内的最小验证。 +## 重点检查 -# 核心能力与约束 -1. 编码前必须逐项检查以下维度:复用、架构边界、业务规则口径、数据契约、资源与性能。 - - 输出前至少复查 4 遍:遗漏、误删、回归、验证口径是否真实。 -2. 自检清单。 - - 是否完整读取并遵守了 Spec Bundle。 - - 是否复用了现有实现,而不是重复造轮子。 - - 是否遵循项目约定的技术栈和编码风格。 - - 是否规避 InputStream、OutputStream、ByteBuffer、线程池、消息队列回调等资源泄露风险。 - - 是否补齐核心主流程、关键分支、异常路径测试。 - - 是否保持架构、业务规则、数据契约与实现的一致顺序。 - - 能枚举化的值不要进行纯字符串硬编码,要么枚举类,要么常量类。 -3. 业务实现口径。 - - 遵循项目已有的业务规则和数据模型约定。 - - 遵循项目已有的实体识别、关联匹配和数据覆盖更新策略。 - - 对多语言、多地区的数据格式保持兼容。 -4. 可用工具习惯。 - - 优先搜索现有代码、测试、Spec Bundle 和 repo-local memory,再决定修改点。 - - 可以联网搜索同类实现,但必须回到当前仓库规则落地。 - - 只要 Spec Bundle 已完整且 PM 已放行,就直接落代码、补测试、跑验证;不要把"是否开始实现"再次交给 PM 或用户确认。 -5. 记忆与交付。 - - 你的 repo-local 记忆目录是 `memories/engineer/`,与 `开发工程师` 共享。 - - 每次实现前优先读取当前任务的 `requirements.md`、`design.md`、`tasks.md` 与相关 repo-local memory。 - - 交付时必须给出设计决策、修改点、风险点、自检结论和建议测试范围。 -6. handoff 约束。 - - 你没有 handoff 权,也不负责决定下一阶段。 - - 自检通过后,只上报"建议进入测试";是否真的进入验证由 `资深项目经理` 决定。 - - 如果任务阻塞、需求边界变化、需要重新拆解,只输出阻塞原因和建议,不自行分发。 +1. 是否破坏 platform / runtime / product 层边界。 +2. 是否把 GitLab 业务类型泄漏到通用 Runtime。 +3. 幂等、重试、超时、并发、资源释放和错误流转是否可靠。 +4. API、DTO、schema、配置默认值是否兼容已有调用方。 +5. 是否缺少必要的最小测试或 dry-run 覆盖。 -# 执行优先规则 -1. 默认交付物是已提交到工作区的代码和测试,不是实现计划。 -2. 如果能在当前上下文中确定默认值,就直接采用保守默认值落地,并在结果中说明。 -3. 若当前任务的 Spec Bundle 不完整或与代码现场明显冲突,必须先返回阻塞,不得跳过 spec 直接编码。 -3.1 若 Spec Bundle 只是模板骨架、未完成 PM 审阅,必须返回阻塞,不得把模板文件视为可直接实现的 spec。 -4. 架构、业务规则、数据契约三层结论必须在同一轮实现前基于 Spec Bundle 收敛,不得拆成多个分析 agent 往返消耗 token。 -4.1 若设计决策或前置依赖仍未在 spec 中固定,必须先指出并阻塞,不得在代码里私自补全关键业务决定。 -5. 遇到明显且同任务强相关的编译错误、测试错误、契约错配或资源释放缺陷,应在同一轮内顺手修掉,不要把显然可以内部解决的问题抛回 PM。 -6. 代码改动默认先跑与改动直接相关的最小验证集,再补跑项目全量构建;如果全量构建超过合理时长或出现挂起迹象,必须先返回已完成的最小验证结果、挂起位置和剩余缺口,不得无限等待。 -7. 未拿到真实执行结果前,禁止在 `summary` 中写"验证通过"或"建议进入测试";不得用计划中的命令替代已经执行的命令。 -8. 若当前任务只是既有任务的小幅 refinement,默认继续在当前任务内推进;若实现发现目标已经实质变更、与原范围重叠过低或原任务可以独立收口,必须提醒 PM 考虑拆为新任务。 +## 输出 -# 防挂起与超时硬规则 -1. 所有终端命令必须遵守以下超时限制,超时后必须立即终止命令并输出已完成步骤与最后可见输出: - - 单个测试类/文件运行:最长 120 秒。 - - 模块级测试:最长 300 秒。 - - 全量构建:最长 600 秒。 - - 任何其他终端命令:最长 180 秒。 -2. 执行构建/测试命令时,必须遵守渐进式验证策略,按以下顺序递进,上一步完成后才允许进入下一步: - - 第一步:编译确认。 - - 第二步:运行与改动最直接相关的单个测试类/文件。 - - 第三步:运行改动模块的全部测试。 - - 第四步:仅在前三步通过且时间允许时,才运行全量构建。 - - 若任一步骤超时或挂起,立即停止后续步骤,返回已完成验证和阻塞点。 -3. 禁止在单次会话中读取超过 15 个文件或累计超过 3000 行代码后仍未开始产出任何代码改动;若读取量接近限制,必须停止扩大读取范围并基于已有信息开始实现。 -4. 若测试依赖外部服务(消息队列、对象存储、数据库等),必须确认测试有超时保护;若外部服务在 15 秒内未建立连接,必须判定为环境不可达并返回 blocked,不得无限等待。 -5. 禁止在终端中运行交互式命令或需要用户手动输入确认的命令。 -6. 若某一步骤已成功完成但后续步骤挂起,必须保留已成功的结果并输出部分完成状态(`status=ready` 配合 artifacts 标注未完成项),不得因后续挂起而丢弃全部进度。 +只返回 ReviewStageResult JSON,不要写 Markdown 解释: -# 统一 JSON 结果模板 -1. `task_id`: 任务唯一标识。 -2. `current_stage`: 固定填写 `implementation`。 -3. `status`: `ready`、`blocked`、`need_clarification` 三选一。 -4. `summary`: 必须使用固定句式:`实现结论:已完成{改动范围};验证{tests_run 摘要};默认值{default_values_used 摘要};建议进入{recommended_next_stage}。` -5. `artifacts`: 至少包含以下子字段: - - `design_decisions` - - `changed_files` - - `tests_run` - - `self_review` - - `default_values_used` -5.1 `artifacts` 在条件允许时还应补充 `traceability_links`、`drift_findings`。 -5.2 若启用了架构拆解,`artifacts` 还应包含 `sub_tasks` 数组: - - 每个子任务包含 `sub_task_id`、`description`、`files_involved`、`dependencies`、`parallel_ready`、`assigned_to`(`tech-architect` 或 `developer`)。 -6. `risks`: 剩余风险数组。 -7. `recommended_next_stage`: 只能填写 `verification`、`implementation`、`closed` 之一。 -8. `needs_pm_attention`: `true|false`。 +```json +{ + "stage": "implementation", + "status": "ok", + "summary": "架构审查结论。", + "findings": [], + "nextActions": [] +} +``` +finding 字段只使用:`title`、`body`、`severity`、`category`、`file`、`oldLine`、`newLine`、`source`。`source` 固定为 `tech-architect`。 From e6bda1daf1c6942f2e3c86d4dbe21935e539f933 Mon Sep 17 00:00:00 2001 From: legendPei <804141866@qq.com> Date: Sat, 2 May 2026 11:11:07 +0800 Subject: [PATCH 10/60] feat(gitlab): support commit review summaries --- .../src/review/gitlab-controller.test.ts | 91 +++++++++++++++++++ .../nine1bot/src/review/gitlab-controller.ts | 11 ++- .../04-current-state-and-next-plan.md | 18 ++-- .../platform-gitlab/src/review/api-client.ts | 17 +++- .../platform-gitlab/src/review/publisher.ts | 4 +- .../test/gitlab-review.test.ts | 77 ++++++++++++++++ 6 files changed, 205 insertions(+), 13 deletions(-) diff --git a/packages/nine1bot/src/review/gitlab-controller.test.ts b/packages/nine1bot/src/review/gitlab-controller.test.ts index dcaf2595..fbbaae87 100644 --- a/packages/nine1bot/src/review/gitlab-controller.test.ts +++ b/packages/nine1bot/src/review/gitlab-controller.test.ts @@ -335,4 +335,95 @@ describe('GitLab review controller', () => { 'https://gitlab.example.com/api/v4/projects/123/merge_requests/10/notes', ]) }) + + test('loads live commit diff and publishes a commit summary comment', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = [] + const fetchMock = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }) + if (String(url).includes('/diff')) { + return Response.json([{ + old_path: 'src/commit.ts', + new_path: 'src/commit.ts', + diff: '@@ -1,2 +1,3 @@\n context\n+changed\n', + }]) + } + return Response.json({ id: 1 }) + }) as typeof fetch + + const accepted = await handleGitLabReviewWebhook({ + payload: { + object_kind: 'note', + project: { + id: 123, + web_url: 'https://gitlab.example.com/nine1/nine1bot', + }, + object_attributes: { + id: 99, + note: '@Nine1bot review commit', + }, + commit: { + id: 'commit-sha', + }, + }, + headers: { 'x-gitlab-token': 'secret' }, + platforms: { + gitlab: { + enabled: true, + settings: { + ...platforms.gitlab?.settings, + 'review.dryRun': false, + 'review.baseUrl': 'https://gitlab.example.com', + }, + }, + }, + secrets: liveSecrets, + fetch: fetchMock, + }) + + expect(accepted).toMatchObject({ + accepted: true, + status: 'accepted', + idempotencyKey: 'gitlab:gitlab.example.com:123:commit:commit-sha:note:99', + }) + if (!accepted.accepted) throw new Error('expected accepted commit review run') + + const published = await publishGitLabReviewRunResult({ + runId: accepted.runId, + platforms: { + gitlab: { + enabled: true, + settings: { + ...platforms.gitlab?.settings, + 'review.dryRun': false, + 'review.baseUrl': 'https://gitlab.example.com', + }, + }, + }, + secrets: liveSecrets, + fetch: fetchMock, + stageResult: { + stage: 'verification', + status: 'ok', + summary: 'Commit review complete.', + findings: [{ + title: 'Changed line', + body: 'Commit finding body', + severity: 'major', + file: 'src/commit.ts', + newLine: 2, + }], + }, + }) + + expect(published).toMatchObject({ + published: true, + inlinePosted: 0, + fallbackPosted: 0, + }) + expect(calls.map((call) => call.url)).toEqual([ + 'https://gitlab.example.com/api/v4/projects/123/repository/commits/commit-sha/diff', + 'https://gitlab.example.com/api/v4/projects/123/repository/commits/commit-sha/comments', + ]) + expect(String(calls[1]?.init?.body)).toContain('note=') + }) }) diff --git a/packages/nine1bot/src/review/gitlab-controller.ts b/packages/nine1bot/src/review/gitlab-controller.ts index 8051f595..ef0208bd 100644 --- a/packages/nine1bot/src/review/gitlab-controller.ts +++ b/packages/nine1bot/src/review/gitlab-controller.ts @@ -274,13 +274,18 @@ async function loadLiveChanges(input: { secrets: PlatformSecretAccess fetch?: typeof fetch }): Promise { - if (input.trigger.objectType !== 'mr') return undefined if (input.settings.dryRun) return undefined const baseUrl = input.settings.baseUrl ?? `https://${input.trigger.host}` const token = await resolveGitLabReviewSecret(input.settings.tokenSecretRef, input.secrets) - if (!token || !input.trigger.objectIid) return undefined + if (!token) return undefined const client = new GitLabApiClient({ baseUrl, token, fetch: input.fetch }) - return await client.getMergeRequestChanges(input.trigger.projectId, input.trigger.objectIid) + if (input.trigger.objectType === 'mr' && input.trigger.objectIid) { + return await client.getMergeRequestChanges(input.trigger.projectId, input.trigger.objectIid) + } + if (input.trigger.objectType === 'commit' && input.trigger.commitSha) { + return await client.getCommitDiff(input.trigger.projectId, input.trigger.commitSha) + } + return undefined } async function maybeWriteBlockedComment(input: { diff --git a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md index 36eb9ef2..5b05e497 100644 --- a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md +++ b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md @@ -83,6 +83,7 @@ - 计算 idempotency key - 对已 accepted 的 run 做幂等去重 - 非 dry-run 时拉取真实 MR changes + - commit mention 触发时拉取真实 commit diff - 构建 review context - overflow 时阻断并向 MR 写 blocked 评论 - 非 dry-run、未阻断时启动 Runtime session @@ -119,6 +120,10 @@ - 通过校验的 inline discussions - 最终 top-level summary note - inline fallback 详情 +- commit review 采用保守发布策略: + - 拉取 `/repository/commits/:sha/diff` + - 向 `/repository/commits/:sha/comments` 写顶层 summary + - 暂不使用 MR discussion position 生成 commit inline comment - dry-run 下不会触碰 GitLab,会返回拒绝发布结果。 ## 已验证命令 @@ -143,7 +148,7 @@ | GitLab 包边界 | GitLab 专属代码放在 `platform-gitlab` | parsing、diff、API、publishing、skills、agents 已放入 | Phase 0/1 无明显差距 | | Agents / skills | Runtime 执行 PM,PM 用 skills 创建自定义子代理 | 资产已注册,PM/子代理 prompt 已收紧为 GitLab review 只读模式 | 还需要真实 subagent task tool contract 的端到端验证 | | Web 配置开关 | 默认关闭,通过平台设置启用 | descriptor 已暴露配置项,默认关闭 | 还没有 GitLab 专属引导 UI | -| Webhook 触发 | GitLab MR / note webhook 与 `@Nine1bot` | `/webhooks/gitlab` 已解析 MR 和 note payload | commit diff live fetch 还没接 | +| Webhook 触发 | GitLab MR / note webhook 与 `@Nine1bot` | `/webhooks/gitlab` 已解析 MR 和 note payload,commit mention 已能拉 diff 并写 summary | commit inline comment 暂未实现 | | 幂等性 | MR key 必须包含 `headSha` | 已实现并测试 | store 仍是内存实现 | | Diff 安全 | 过滤噪声,overflow 阻断 | 已实现并测试 | 需要更多真实 GitLab 大 MR payload fixture | | Inline 安全 | 校验 hunk,非法或 400 fallback | 已实现并测试 | 当前阶段无明显差距 | @@ -166,16 +171,15 @@ - 扩展 dry-run harness,使其能注入一段 PM 输出文本并验证自动发布链路。 - 验证 PM 通过 runtime subagent/task 能力创建自定义子代理时,promptRef、skills、timeout、failureMode 能被正确传入。 -### 2. GitLab Commit Review +### 2. Commit Inline Comment 增强 -目标:支持 commit 评论触发场景。 +目标:在保守 summary 发布之外,评估是否支持 GitLab commit 行级评论。 任务: -- 在 `GitLabApiClient` 增加 commit diff 拉取方法。 -- commit diff 复用当前 filter / overflow guard。 -- 通过 `repository/commits/:sha/notes` 发布 commit review note。 -- 增加 commit note webhook fixture 和测试。 +- 调研并验证 GitLab commit comments 的 `path`、`line`、`line_type` 参数在不同 GitLab 版本中的行为。 +- 设计与 MR inline position 分离的 commit line validator。 +- 仅在代码侧校验通过时启用 commit inline;否则继续 summary fallback。 ### 3. ReviewRun 持久化 diff --git a/packages/platform-gitlab/src/review/api-client.ts b/packages/platform-gitlab/src/review/api-client.ts index 9e403a89..42320c36 100644 --- a/packages/platform-gitlab/src/review/api-client.ts +++ b/packages/platform-gitlab/src/review/api-client.ts @@ -45,10 +45,23 @@ export class GitLabApiClient { ) } + async getCommitDiff(projectId: string | number, commitSha: string | number): Promise { + const changes = await this.request( + `/api/v4/projects/${encodeURIComponent(String(projectId))}/repository/commits/${encodeURIComponent(String(commitSha))}/diff`, + ) + return { changes: changes ?? [] } + } + async createNote(input: GitLabCreateNoteInput): Promise { - return await this.request(`/api/v4/projects/${encodeURIComponent(String(input.projectId))}/${input.resource}/${encodeURIComponent(String(input.resourceId))}/notes`, { + const notePath = input.resource === 'repository/commits' + ? `/api/v4/projects/${encodeURIComponent(String(input.projectId))}/repository/commits/${encodeURIComponent(String(input.resourceId))}/comments` + : `/api/v4/projects/${encodeURIComponent(String(input.projectId))}/merge_requests/${encodeURIComponent(String(input.resourceId))}/notes` + const body = input.resource === 'repository/commits' + ? new URLSearchParams({ note: input.body }) + : new URLSearchParams({ body: input.body }) + return await this.request(notePath, { method: 'POST', - body: new URLSearchParams({ body: input.body }), + body, }) } diff --git a/packages/platform-gitlab/src/review/publisher.ts b/packages/platform-gitlab/src/review/publisher.ts index 8bb5fd15..223e15f4 100644 --- a/packages/platform-gitlab/src/review/publisher.ts +++ b/packages/platform-gitlab/src/review/publisher.ts @@ -31,7 +31,7 @@ export async function publishGitLabReviewResult(input: PublishGitLabReviewInput) let fallbackPosted = 0 const fallbackMarkdown: string[] = [] - if (input.inlineComments) { + if (input.inlineComments && input.objectType === 'mr') { for (const finding of aggregated) { const validation = validateGitLabInlinePosition(finding, input.manifest.files, input.manifest.diffRefs) if (!validation.ok) { @@ -59,6 +59,8 @@ export async function publishGitLabReviewResult(input: PublishGitLabReviewInput) throw error } } + } else if (input.inlineComments && input.objectType === 'commit') { + warnings.push('Inline comments are skipped for commit review runs; findings are included in the summary comment.') } const summaryBody = [ diff --git a/packages/platform-gitlab/test/gitlab-review.test.ts b/packages/platform-gitlab/test/gitlab-review.test.ts index 9d5cd207..ed7314e4 100644 --- a/packages/platform-gitlab/test/gitlab-review.test.ts +++ b/packages/platform-gitlab/test/gitlab-review.test.ts @@ -149,6 +149,39 @@ describe('GitLab review foundation', () => { }) }) + test('parses commit mention note webhooks into review triggers', () => { + const result = parseGitLabWebhookEvent({ + object_kind: 'note', + project: { + id: 123, + path_with_namespace: 'nine1/nine1bot', + web_url: 'https://gitlab.example.com/nine1/nine1bot', + }, + object_attributes: { + id: 778, + note: '@Nine1bot review commit', + }, + commit: { + id: 'commit123', + }, + }, { + ...defaultGitLabReviewSettings, + enabled: true, + allowedHosts: ['gitlab.example.com'], + allowedProjectIds: [123], + }) + + expect(result).toMatchObject({ + ok: true, + trigger: { + objectType: 'commit', + commitSha: 'commit123', + noteId: 778, + mode: 'mention', + }, + }) + }) + test('builds review context blocks from trigger and changes', () => { const context = buildGitLabReviewContext({ trigger: { @@ -284,4 +317,48 @@ describe('GitLab review foundation', () => { expect(result).toMatchObject({ inlinePosted: 0, fallbackPosted: 1 }) expect(result.warnings[0]).toContain('GitLab API returned 400') }) + + test('publishes commit reviews as summary comments without inline discussions', async () => { + const manifest = buildGitLabDiffManifest({ + changes: [{ + old_path: 'src/app.ts', + new_path: 'src/app.ts', + diff: '@@ -1,2 +1,3 @@\n context\n+changed\n', + }], + }) + const calls: string[] = [] + const result = await publishGitLabReviewResult({ + client: { + async createDiscussion() { + calls.push('discussion') + return {} + }, + async createNote() { + calls.push('note') + return {} + }, + }, + projectId: 123, + objectType: 'commit', + objectId: 'commit123', + manifest, + summary: 'Commit review complete.', + inlineComments: true, + findings: [{ + title: 'Changed line', + body: 'Commit finding body', + severity: 'major', + file: 'src/app.ts', + newLine: 2, + }], + }) + + expect(calls).toEqual(['note']) + expect(result).toMatchObject({ + summaryPosted: true, + inlinePosted: 0, + fallbackPosted: 0, + }) + expect(result.warnings[0]).toContain('Inline comments are skipped for commit review runs') + }) }) From eb6dba8216f2ff2936645866921c083b92a2f8fc Mon Sep 17 00:00:00 2001 From: legendPei <804141866@qq.com> Date: Sat, 2 May 2026 11:14:13 +0800 Subject: [PATCH 11/60] feat(gitlab): persist review runs --- .../src/review/gitlab-controller.test.ts | 40 ++++++++- packages/nine1bot/src/review/run-store.ts | 90 +++++++++++++++++++ .../04-current-state-and-next-plan.md | 24 +++-- 3 files changed, 138 insertions(+), 16 deletions(-) diff --git a/packages/nine1bot/src/review/gitlab-controller.test.ts b/packages/nine1bot/src/review/gitlab-controller.test.ts index fbbaae87..117ae7e3 100644 --- a/packages/nine1bot/src/review/gitlab-controller.test.ts +++ b/packages/nine1bot/src/review/gitlab-controller.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' import { extractGitLabReviewStageResultFromRuntimeText, handleGitLabReviewWebhook, @@ -52,11 +55,20 @@ const platforms = { }, } +const tempDirs: string[] = [] + describe('GitLab review controller', () => { - beforeEach(() => { + beforeEach(async () => { + const dir = await mkdtemp(join(tmpdir(), 'nine1bot-review-runs-')) + tempDirs.push(dir) + ReviewRunStore.setPathForTesting(join(dir, 'review-runs.json')) ReviewRunStore.clearForTesting() }) + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) + }) + test('extracts runtime review results from fenced output', () => { const extracted = extractGitLabReviewStageResultFromRuntimeText([ 'Review complete.', @@ -174,6 +186,30 @@ describe('GitLab review controller', () => { expect(first.accepted && second.accepted && second.duplicateOf).toBe(first.accepted && first.runId) }) + test('persists review runs between store reloads', async () => { + const created = ReviewRunStore.create({ + platform: 'gitlab', + idempotencyKey: 'gitlab:example:123:commit:abc:auto:test', + status: 'accepted', + trigger: { objectType: 'commit', commitSha: 'abc' }, + }) + ReviewRunStore.update(created.id, { + status: 'running', + sessionId: 'session_123', + }) + + ReviewRunStore.reloadForTesting() + + expect(ReviewRunStore.get(created.id)).toMatchObject({ + id: created.id, + status: 'running', + sessionId: 'session_123', + }) + expect(ReviewRunStore.findByIdempotencyKey('gitlab:example:123:commit:abc:auto:test')).toMatchObject({ + id: created.id, + }) + }) + test('loads live MR changes and writes blocked comments for overflow diffs', async () => { const calls: Array<{ url: string; init?: RequestInit }> = [] const fetchMock = (async (url: string | URL | Request, init?: RequestInit) => { diff --git a/packages/nine1bot/src/review/run-store.ts b/packages/nine1bot/src/review/run-store.ts index 9b05281d..260f02e7 100644 --- a/packages/nine1bot/src/review/run-store.ts +++ b/packages/nine1bot/src/review/run-store.ts @@ -1,3 +1,7 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { dirname, join } from 'path' +import { getDataDir } from '../config/loader' + export type ReviewRunStatus = 'accepted' | 'rejected' | 'blocked' | 'running' | 'succeeded' | 'failed' export type ReviewRunRecord = { @@ -18,11 +22,28 @@ export type ReviewRunRecord = { export type CreateReviewRunInput = Omit +type ReviewRunStoreFile = { + version: 1 + sequence: number + runs: ReviewRunRecord[] +} + const runs = new Map() let sequence = 0 +let loaded = false +let storePathOverride: string | undefined + +function defaultStorePath() { + return process.env.NINE1BOT_REVIEW_RUN_STORE_PATH || join(getDataDir(), 'review-runs.json') +} + +function storePath() { + return storePathOverride || defaultStorePath() +} export namespace ReviewRunStore { export function create(input: CreateReviewRunInput): ReviewRunRecord { + load() const now = Date.now() const run = { ...input, @@ -31,10 +52,12 @@ export namespace ReviewRunStore { updatedAt: now, } satisfies ReviewRunRecord runs.set(run.id, run) + save() return { ...run } } export function findByIdempotencyKey(idempotencyKey: string): ReviewRunRecord | undefined { + load() for (const run of runs.values()) { if (run.idempotencyKey === idempotencyKey) return { ...run } } @@ -42,11 +65,13 @@ export namespace ReviewRunStore { } export function get(id: string): ReviewRunRecord | undefined { + load() const run = runs.get(id) return run ? { ...run } : undefined } export function update(id: string, patch: Partial>): ReviewRunRecord | undefined { + load() const existing = runs.get(id) if (!existing) return undefined const next = { @@ -55,15 +80,80 @@ export namespace ReviewRunStore { updatedAt: Date.now(), } runs.set(id, next) + save() return { ...next } } export function list(): ReviewRunRecord[] { + load() return [...runs.values()].map((run) => ({ ...run })) } export function clearForTesting() { runs.clear() sequence = 0 + loaded = true + if (storePathOverride && existsSync(storePathOverride)) { + rmSync(storePathOverride, { force: true }) + } + } + + export function setPathForTesting(filepath: string) { + storePathOverride = filepath + runs.clear() + sequence = 0 + loaded = false + } + + export function reloadForTesting() { + runs.clear() + sequence = 0 + loaded = false + } +} + +function load() { + if (loaded) return + loaded = true + const filepath = storePath() + if (!existsSync(filepath)) return + try { + const parsed = JSON.parse(readFileSync(filepath, 'utf-8')) as Partial + const records = Array.isArray(parsed.runs) ? parsed.runs.filter(isReviewRunRecord) : [] + runs.clear() + for (const run of records) { + runs.set(run.id, { ...run }) + } + sequence = typeof parsed.sequence === 'number' && Number.isFinite(parsed.sequence) + ? parsed.sequence + : inferSequence(records) + } catch { + runs.clear() + sequence = 0 } } + +function save() { + const filepath = storePath() + mkdirSync(dirname(filepath), { recursive: true }) + const data: ReviewRunStoreFile = { + version: 1, + sequence, + runs: [...runs.values()], + } + writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf-8') +} + +function inferSequence(records: ReviewRunRecord[]) { + return records.length +} + +function isReviewRunRecord(input: unknown): input is ReviewRunRecord { + if (!input || typeof input !== 'object' || Array.isArray(input)) return false + const record = input as Record + return typeof record.id === 'string' + && record.platform === 'gitlab' + && typeof record.status === 'string' + && typeof record.createdAt === 'number' + && typeof record.updatedAt === 'number' +} diff --git a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md index 5b05e497..5d42f54b 100644 --- a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md +++ b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md @@ -75,7 +75,10 @@ - 产品层 controller: - `packages/nine1bot/src/review/gitlab-controller.ts` - `packages/nine1bot/src/review/run-store.ts` -- 当前 `ReviewRunStore` 是进程内存实现,作为第一版最小闭环。 +- 当前 `ReviewRunStore` 已从纯内存实现升级为 JSON 文件持久化实现: + - 默认路径:Nine1Bot data dir 下的 `review-runs.json` + - 支持按 `idempotencyKey` 跨进程重启去重 + - 持久化 trigger、context、sessionId、turnSnapshotId、warnings、publishedAt 等发布/重试所需上下文 - webhook 当前链路: - 校验 GitLab webhook token - 解析 MR / note webhook @@ -149,7 +152,7 @@ | Agents / skills | Runtime 执行 PM,PM 用 skills 创建自定义子代理 | 资产已注册,PM/子代理 prompt 已收紧为 GitLab review 只读模式 | 还需要真实 subagent task tool contract 的端到端验证 | | Web 配置开关 | 默认关闭,通过平台设置启用 | descriptor 已暴露配置项,默认关闭 | 还没有 GitLab 专属引导 UI | | Webhook 触发 | GitLab MR / note webhook 与 `@Nine1bot` | `/webhooks/gitlab` 已解析 MR 和 note payload,commit mention 已能拉 diff 并写 summary | commit inline comment 暂未实现 | -| 幂等性 | MR key 必须包含 `headSha` | 已实现并测试 | store 仍是内存实现 | +| 幂等性 | MR key 必须包含 `headSha` | 已实现并测试,run store 已持久化 | 后续可增加过期/清理策略 | | Diff 安全 | 过滤噪声,overflow 阻断 | 已实现并测试 | 需要更多真实 GitLab 大 MR payload fixture | | Inline 安全 | 校验 hunk,非法或 400 fallback | 已实现并测试 | 当前阶段无明显差距 | | Map-reduce findings | 代码侧聚合后交给 PM | aggregator 已实现 | 尚未接真实多 agent stage outputs | @@ -181,22 +184,15 @@ - 设计与 MR inline position 分离的 commit line validator。 - 仅在代码侧校验通过时启用 commit inline;否则继续 summary fallback。 -### 3. ReviewRun 持久化 +### 3. ReviewRun 运维增强 -目标:替换当前内存版 `ReviewRunStore`。 +目标:在已持久化的基础上补齐可运营能力。 任务: -- 复用项目已有存储模式。 -- 按 `idempotencyKey` 持久化 run record。 -- 保留发布/重试所需上下文: - - trigger - - diff refs - - manifest - - warnings - - sessionId - - turnSnapshotId - - publish status +- 增加 run 记录过期或最大数量清理策略,避免长期无限增长。 +- 为手动重试设计状态流转:允许 failed/blocked run 复制上下文后重新执行。 +- 在 Web UI 中展示持久化 run 的 sessionId、turnSnapshotId、publish status 和 warnings。 ### 4. Web UX From 811e2e3f79ff35d246a631c5d6019a395b5adc65 Mon Sep 17 00:00:00 2001 From: legendPei <804141866@qq.com> Date: Sat, 2 May 2026 11:16:04 +0800 Subject: [PATCH 12/60] feat(gitlab): expand review dry run harness --- .../04-current-state-and-next-plan.md | 19 +- .../review/sample-webhook-mr-note.json | 38 +++ packages/platform-gitlab/package.json | 3 +- .../platform-gitlab/scripts/review-dry-run.ts | 238 ++++++++++++++---- 4 files changed, 241 insertions(+), 57 deletions(-) create mode 100644 packages/platform-gitlab/fixtures/review/sample-webhook-mr-note.json diff --git a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md index 5d42f54b..bd729a5f 100644 --- a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md +++ b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md @@ -42,6 +42,8 @@ - `scripts/review-dry-run.ts` - 正常 MR changes fixture - overflow MR changes fixture + - webhook note fixture + - `review:dry-run:webhook` 可在无真实 GitLab/Runtime 的情况下跑通 webhook parse、context build、stage result 和 mock publish ### 设计审查中提出的安全规则 @@ -138,6 +140,7 @@ - `bun test packages/nine1bot/src/review/gitlab-controller.test.ts` - `bun test packages/nine1bot/src/platform/manager.test.ts` - `bun run review:dry-run fixtures/review/sample-mr-overflow.json` +- `bun run review:dry-run:webhook` in `packages/platform-gitlab` - `bun run typecheck` in `opencode/packages/opencode` 已再次验证,仍只失败在 workspace 包 `@nine1bot/platform-protocol` 的 standalone 解析问题上。 已知验证 caveat: @@ -159,7 +162,7 @@ | Runtime 边界 | Runtime 只处理通用 schema/result | review 类型由 platform/controller 拥有,自动控制器只暴露通用 runtime output | 当前阶段无明显差距 | | Runtime 结果捕获 | PM 最终结构化结果自动发布 | 已从 `message.part.updated` 捕获 fenced JSON 并发布 | 还需要真实端到端 fixture 覆盖 streaming 与异常输出 | | Failure policy | subagent spec 包含 `failureMode` | 类型和初始 task specs 已有 | Runtime 内 PM 创建子代理的实际 tool contract 仍需确认/实现 | -| Dry-run harness | 初期必须有 | 已实现 | 可继续扩展 webhook payload fixture 模式 | +| Dry-run harness | 初期必须有 | 已支持 changes fixture 与 webhook fixture,本地可跑通 mock publish | 后续可接入 PM 输出文本注入与异常 JSON 场景 | ## 下一步计划 @@ -205,19 +208,15 @@ - 展示 review run 状态:`GET /webhooks/gitlab/runs`。 - 展示 dry-run、blocked、duplicate、published 等状态。 -### 5. 端到端测试桩 +### 5. 端到端测试桩增强 -目标:不用真实 GitLab 项目也能跑通完整链路。 +目标:在当前本地桩基础上覆盖更多真实失败模式。 任务: -- 扩展 dry-run CLI,支持 webhook payload fixtures。 -- mock GitLab API 的 changes、notes、discussions。 -- 增加一条脚本串起: - - webhook parse - - live changes fetch mock - - Runtime prompt/context compile boundary - - publish fallback paths +- 注入 PM 输出文本,验证 `GITLAB_REVIEW_RESULT` 提取和发布入口。 +- 增加非法 JSON、缺字段 JSON、blocked diff、inline fallback、GitLab 400 fallback 的 fixture 模式。 +- 如后续 Runtime subagent/task contract 固定,再把 dry-run 扩展到 prompt assembly 边界。 ## 当前提交栈 diff --git a/packages/platform-gitlab/fixtures/review/sample-webhook-mr-note.json b/packages/platform-gitlab/fixtures/review/sample-webhook-mr-note.json new file mode 100644 index 00000000..351e9c5b --- /dev/null +++ b/packages/platform-gitlab/fixtures/review/sample-webhook-mr-note.json @@ -0,0 +1,38 @@ +{ + "object_kind": "note", + "event_type": "note", + "project": { + "id": 123, + "path_with_namespace": "nine1/nine1bot", + "web_url": "https://gitlab.example.com/nine1/nine1bot" + }, + "object_attributes": { + "id": 777, + "note": "@Nine1bot review this merge request" + }, + "merge_request": { + "iid": 10, + "last_commit": { + "id": "dry-run-head" + } + }, + "review_changes": { + "diff_refs": { + "base_sha": "dry-run-base", + "start_sha": "dry-run-start", + "head_sha": "dry-run-head" + }, + "changes": [ + { + "old_path": "src/auth.ts", + "new_path": "src/auth.ts", + "diff": "@@ -1,3 +1,4 @@\n export function canReview(user) {\n+ if (!user) return false\n return user.role === 'maintainer'\n }\n" + }, + { + "old_path": "package-lock.json", + "new_path": "package-lock.json", + "diff": "@@ -1 +1 @@\n-{}\n+{}\n" + } + ] + } +} diff --git a/packages/platform-gitlab/package.json b/packages/platform-gitlab/package.json index b93e2567..df17f89b 100644 --- a/packages/platform-gitlab/package.json +++ b/packages/platform-gitlab/package.json @@ -12,7 +12,8 @@ "scripts": { "test": "bun test", "typecheck": "tsc --noEmit", - "review:dry-run": "bun run scripts/review-dry-run.ts" + "review:dry-run": "bun run scripts/review-dry-run.ts", + "review:dry-run:webhook": "bun run scripts/review-dry-run.ts --webhook fixtures/review/sample-webhook-mr-note.json" }, "dependencies": { "@nine1bot/platform-protocol": "workspace:*" diff --git a/packages/platform-gitlab/scripts/review-dry-run.ts b/packages/platform-gitlab/scripts/review-dry-run.ts index 99034c2e..8345aef7 100644 --- a/packages/platform-gitlab/scripts/review-dry-run.ts +++ b/packages/platform-gitlab/scripts/review-dry-run.ts @@ -3,62 +3,208 @@ import { resolve } from 'node:path' import { aggregateReviewFindings, buildGitLabDiffManifest, + buildGitLabReviewContext, buildGitLabReviewIdempotencyKey, + defaultGitLabReviewSettings, + parseGitLabWebhookEvent, + publishGitLabReviewResult, renderBlockedDiffComment, renderReviewSummaryComment, validateGitLabInlinePosition, type GitLabRawChangesResponse, + type GitLabReviewTrigger, type ReviewFinding, } from '../src/review' -const fixturePath = resolve(process.cwd(), process.argv[2] ?? 'fixtures/review/sample-mr-changes.json') -const fixture = JSON.parse(readFileSync(fixturePath, 'utf8')) as GitLabRawChangesResponse -const manifest = buildGitLabDiffManifest(fixture) - -const idempotencyKey = buildGitLabReviewIdempotencyKey({ - host: 'gitlab.example.com', - projectId: 1, - objectType: 'mr', - objectIid: 10, - headSha: manifest.diffRefs?.headSha ?? 'dry-run-head', - mode: 'webhook', - eventName: 'merge_request', -}) - -if (manifest.blocked) { - console.log(JSON.stringify({ +type DryRunMode = 'changes' | 'webhook' + +const args = process.argv.slice(2) +const mode = parseMode(args) +const fixturePath = resolve(process.cwd(), parseFixturePath(args, mode)) +const fixture = JSON.parse(readFileSync(fixturePath, 'utf8')) + +if (mode === 'webhook') { + await runWebhookDryRun(fixture) +} else { + await runChangesDryRun(fixture as GitLabRawChangesResponse) +} + +async function runChangesDryRun(changes: GitLabRawChangesResponse) { + const trigger: GitLabReviewTrigger = { + host: 'gitlab.example.com', + projectId: 1, + objectType: 'mr', + objectIid: 10, + headSha: changes.diff_refs?.head_sha ?? 'dry-run-head', + mode: 'webhook', + eventName: 'merge_request', + } + const manifest = buildGitLabDiffManifest(changes) + const idempotencyKey = buildGitLabReviewIdempotencyKey(trigger) + + if (manifest.blocked) { + print({ + mode: 'changes', + idempotencyKey, + blocked: true, + comment: renderBlockedDiffComment(manifest.blockReason ?? 'Diff blocked.'), + }) + return + } + + const syntheticFindings = syntheticFindingsForManifest(manifest) + const inline = validateGitLabInlinePosition(syntheticFindings[0]!, manifest.files, manifest.diffRefs) + const findings = aggregateReviewFindings(syntheticFindings) + const comment = renderReviewSummaryComment({ + summary: 'Dry-run completed without calling GitLab or Runtime.', + findings, + manifest, + warnings: inline.ok ? [] : [inline.reason], + }) + + print({ + mode: 'changes', idempotencyKey, - blocked: true, - comment: renderBlockedDiffComment(manifest.blockReason ?? 'Diff blocked.'), - }, null, 2)) - process.exit(0) + blocked: false, + manifest, + inline, + comment, + }) } -const syntheticFindings: ReviewFinding[] = [ - { - title: 'Permission check changed', - body: 'Dry-run fixture detected a changed authorization decision line.', +async function runWebhookDryRun(payload: unknown) { + const parsed = parseGitLabWebhookEvent(payload, { + ...defaultGitLabReviewSettings, + enabled: true, + webhookAutoReview: true, + manualMentionTrigger: true, + botMention: '@Nine1bot', + }) + if (!parsed.ok) { + print({ mode: 'webhook', accepted: false, reason: parsed.reason }) + return + } + + const changes = extractChanges(payload) + if (!changes) { + print({ + mode: 'webhook', + accepted: false, + reason: 'fixture-missing-review-changes', + trigger: parsed.trigger, + }) + return + } + + const context = buildGitLabReviewContext({ + trigger: parsed.trigger, + changes, + }) + + if (context.diff.blocked) { + print({ + mode: 'webhook', + accepted: true, + idempotencyKey: context.idempotencyKey, + blocked: true, + comment: renderBlockedDiffComment(context.diff.blockReason ?? 'Diff blocked.'), + contextBlocks: context.contextBlocks, + }) + return + } + + const stageResult = { + stage: 'closed', + status: 'ok', + summary: 'Dry-run webhook review completed without Runtime or GitLab network calls.', + findings: syntheticFindingsForManifest(context.diff), + nextActions: ['Use this fixture to debug PM prompt and publisher behavior locally.'], + } + const published = await publishGitLabReviewResult({ + client: mockGitLabClient(), + projectId: parsed.trigger.projectId, + objectType: parsed.trigger.objectType, + objectId: parsed.trigger.objectType === 'mr' ? parsed.trigger.objectIid! : parsed.trigger.commitSha!, + manifest: context.diff, + summary: stageResult.summary, + findings: stageResult.findings, + inlineComments: true, + warnings: stageResult.nextActions, + }) + + print({ + mode: 'webhook', + accepted: true, + idempotencyKey: context.idempotencyKey, + trigger: parsed.trigger, + contextBlocks: context.contextBlocks, + stageResult, + published, + }) +} + +function syntheticFindingsForManifest(manifest: ReturnType): ReviewFinding[] { + const firstFile = manifest.files[0] + if (!firstFile) return [] + return [{ + title: 'Dry-run changed line', + body: 'Synthetic finding generated by the local GitLab review dry-run harness.', severity: 'major', - category: 'auth', - file: 'src/auth.ts', - newLine: 3, + category: 'dry-run', + file: firstFile.newPath, + newLine: firstChangedNewLine(firstFile.diff), source: 'dry-run', - }, -] - -const inline = validateGitLabInlinePosition(syntheticFindings[0]!, manifest.files, manifest.diffRefs) -const findings = aggregateReviewFindings(syntheticFindings) -const comment = renderReviewSummaryComment({ - summary: 'Dry-run completed without calling GitLab or Runtime.', - findings, - manifest, - warnings: inline.ok ? [] : [inline.reason], -}) - -console.log(JSON.stringify({ - idempotencyKey, - blocked: false, - manifest, - inline, - comment, -}, null, 2)) + }] +} + +function firstChangedNewLine(diff: string) { + const header = /@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(diff) + let current = header ? Number(header[1]) : 1 + for (const line of diff.split('\n')) { + if (line.startsWith('@@')) continue + if (line.startsWith('+') && !line.startsWith('+++')) return current + if (!line.startsWith('-')) current += 1 + } + return undefined +} + +function mockGitLabClient() { + const calls: Array> = [] + return { + calls, + async createDiscussion(input: Record) { + calls.push({ type: 'discussion', ...input }) + return { id: calls.length } + }, + async createNote(input: Record) { + calls.push({ type: 'note', ...input }) + return { id: calls.length } + }, + } +} + +function extractChanges(payload: unknown): GitLabRawChangesResponse | undefined { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return undefined + const record = payload as Record + if (isRecord(record.review_changes)) return record.review_changes as GitLabRawChangesResponse + if (isRecord(record.changes)) return record.changes as GitLabRawChangesResponse + return undefined +} + +function parseMode(args: string[]): DryRunMode { + return args.includes('--webhook') ? 'webhook' : 'changes' +} + +function parseFixturePath(args: string[], mode: DryRunMode) { + const flagIndex = args.indexOf('--webhook') + if (flagIndex >= 0) return args[flagIndex + 1] ?? 'fixtures/review/sample-webhook-mr-note.json' + return args[0] ?? (mode === 'webhook' ? 'fixtures/review/sample-webhook-mr-note.json' : 'fixtures/review/sample-mr-changes.json') +} + +function isRecord(input: unknown): input is Record { + return Boolean(input && typeof input === 'object' && !Array.isArray(input)) +} + +function print(input: unknown) { + console.log(JSON.stringify(input, null, 2)) +} From 727b5f3bac5a458966dbbbebb7e0634aa500ebd2 Mon Sep 17 00:00:00 2001 From: legendPei <804141866@qq.com> Date: Sat, 2 May 2026 11:20:07 +0800 Subject: [PATCH 13/60] feat(gitlab): show review runs in platform UI --- .../04-current-state-and-next-plan.md | 23 +++- web/src/api/client.ts | 26 ++++ web/src/components/PlatformManager.vue | 119 ++++++++++++++++++ web/test/config-api.test.ts | 31 +++++ 4 files changed, 194 insertions(+), 5 deletions(-) diff --git a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md index bd729a5f..c6258943 100644 --- a/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md +++ b/packages/platform-gitlab/docs/review-implementation/04-current-state-and-next-plan.md @@ -131,6 +131,19 @@ - 暂不使用 MR discussion position 生成 commit inline comment - dry-run 下不会触碰 GitLab,会返回拒绝发布结果。 +### Web UX + +已实现: + +- 通用平台配置页已经能展示 GitLab review descriptor 中的开关和密钥字段。 +- GitLab 平台详情页会额外展示最近的 GitLab Review Runs: + - run id + - MR / Commit 对象 + - status + - updatedAt + - published 标记 +- Web API client 新增 `gitLabReviewApi.runs()`,读取 `/webhooks/gitlab/runs`。 + ## 已验证命令 已通过: @@ -153,7 +166,7 @@ | --- | --- | --- | --- | | GitLab 包边界 | GitLab 专属代码放在 `platform-gitlab` | parsing、diff、API、publishing、skills、agents 已放入 | Phase 0/1 无明显差距 | | Agents / skills | Runtime 执行 PM,PM 用 skills 创建自定义子代理 | 资产已注册,PM/子代理 prompt 已收紧为 GitLab review 只读模式 | 还需要真实 subagent task tool contract 的端到端验证 | -| Web 配置开关 | 默认关闭,通过平台设置启用 | descriptor 已暴露配置项,默认关闭 | 还没有 GitLab 专属引导 UI | +| Web 配置开关 | 默认关闭,通过平台设置启用 | descriptor 已暴露配置项,默认关闭;GitLab 平台详情页已展示 review runs | 还需要更完整的 GitLab 专属引导文案和 webhook URL 展示 | | Webhook 触发 | GitLab MR / note webhook 与 `@Nine1bot` | `/webhooks/gitlab` 已解析 MR 和 note payload,commit mention 已能拉 diff 并写 summary | commit inline comment 暂未实现 | | 幂等性 | MR key 必须包含 `headSha` | 已实现并测试,run store 已持久化 | 后续可增加过期/清理策略 | | Diff 安全 | 过滤噪声,overflow 阻断 | 已实现并测试 | 需要更多真实 GitLab 大 MR payload fixture | @@ -197,16 +210,16 @@ - 为手动重试设计状态流转:允许 failed/blocked run 复制上下文后重新执行。 - 在 Web UI 中展示持久化 run 的 sessionId、turnSnapshotId、publish status 和 warnings。 -### 4. Web UX +### 4. Web UX 增强 -目标:让用户能清楚配置 GitLab review。 +目标:在已有配置表单和 Review Runs 状态块基础上补齐引导体验。 任务: - 如果 generic platform form 不够清晰,增加 GitLab 专属帮助文案或 custom component。 - 展示 webhook URL:`/webhooks/gitlab`。 -- 展示 review run 状态:`GET /webhooks/gitlab/runs`。 -- 展示 dry-run、blocked、duplicate、published 等状态。 +- 展示 token 权限建议、GitLab webhook 配置步骤和 dry-run 调试入口。 +- 为 blocked、duplicate、published、failed 状态增加更清晰的说明。 ### 5. 端到端测试桩增强 diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 47ce7574..9d58d777 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -491,6 +491,21 @@ export interface WebhookStatus { } } +export interface GitLabReviewRun { + id: string + platform: 'gitlab' + idempotencyKey?: string + status: 'accepted' | 'rejected' | 'blocked' | 'running' | 'succeeded' | 'failed' + createdAt: number + updatedAt: number + error?: string + trigger?: Record + sessionId?: string + turnSnapshotId?: string + publishedAt?: number + warnings?: string[] +} + export interface WebhookSourceInput { name: string enabled?: boolean @@ -2259,6 +2274,17 @@ export async function importAuthFromOpencode(): Promise { return res.json() } +export const gitLabReviewApi = { + async runs(): Promise { + const res = await fetchWithTimeout(`${BASE_URL}/webhooks/gitlab/runs`) + if (!res.ok) { + throw new Error(`Failed to list GitLab review runs: ${res.status}`) + } + const data = await res.json() + return Array.isArray(data.runs) ? data.runs : [] + }, +} + // === Question API === export const questionApi = { // 获取待处理的问题列表 diff --git a/web/src/components/PlatformManager.vue b/web/src/components/PlatformManager.vue index e428f9b2..aa75fafc 100644 --- a/web/src/components/PlatformManager.vue +++ b/web/src/components/PlatformManager.vue @@ -1,6 +1,7 @@