Compare commits

...

4 Commits

Author SHA1 Message Date
10434d4b71 Merge pull request 'feat(delivery/mcp): get_delivery_status 支持按 pr_number/branch 查询,结论绑定 Gitea head SHA(使者回传 #203)' (#32) from feat/delivery-status-by-pr into master
All checks were successful
Push & PR Check / check (push) Successful in 1m1s
2026-10-03 05:36:38 +08:00
lanrtop
df5832f41b fix(delivery/mcp): 补 get_delivery_status 经 scoped dispatch 转发 pr_number/branch 的回归测试(P3-F 审查)
All checks were successful
Push & PR Check / check (push) Successful in 59s
Push & PR Check / check (pull_request) Successful in 1m2s
使者回传 #203 的症状是「传 pr_number 也不起作用」,但原有 MCP 层测试只覆盖非法参数报错,
dispatch 若丢弃已解析的查询目标、改传默认值,全部测试仍通过。新增 scoped dispatch 正向测试,
断言 pr_number/branch/null 分别得到 pull_request/branch/local_branch 目标;已用变异
(dispatch 改传 DeliveryQuery::default())确认该测试会失败。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 06:34:06 +09:00
lanrtop
1e3989d0d7 feat(delivery/mcp): get_delivery_status 支持按 pr_number/branch 查询,结论绑定 Gitea head SHA(使者回传 #203)
All checks were successful
Push & PR Check / check (push) Successful in 1m0s
Push & PR Check / check (pull_request) Successful in 1m2s
Enterprise 2026-10-03 实测:为避开主工作区里其他会话的在制品,在独立 worktree
分支上开 PR 交付时,get_delivery_status 只读主工作区当前分支,返回「当前分支没有
开放 PR」、CI 停在主工作区 HEAD,传 pr_number 也被忽略,只能用 git ls-remote 与
get_ci_status(commit_sha) 兜底。

- MCP schema 新增可选 pr_number(优先)与 branch;都不给时保持原行为
- pr_number:GET /pulls/{n} 取 head 分支与 head SHA,CI 与 review 均绑定该 PR;
  PR 不存在(404)返回 not_found,与 Gitea 不可达(unavailable)区分;
  同时传 branch 且与 PR head 分支不一致时以 pr_number 为准并在 detail 提示
- branch:匹配该分支的开放 PR 并以其 head SHA 查 CI;无开放 PR 时取 Gitea 上
  该分支 head SHA;远端分支不存在时 CI 为 unknown
- 顶层新增 target(mode/pr_number/branch/head_sha/source/detail),
  pull_request.data 增 head_sha;git 字段仍反映主工作区,既有字段兼容
- 取值校验在能力本体 DeliveryQuery 单点实现,MCP 只解析 JSON 类型;显式给出的
  非法值直接报错,不静默回退主工作区
- Gitea 读取拆为本地/PR/分支三种目标,凭证存储注入以便合成 Gitea 测试;
  merge_pull_request 门禁代码未改
- lian-jing skill 第 6 条改为 get_delivery_status(project_id, pr_number),
  base-onboarding 1.8.0→1.9.0(BASE_ONBOARDING_PACK_VERSION、模板
  capability-packs.json、TEMPLATE_FINGERPRINTS);Codex 插件 skill 同步
- 契约说明同步 docs/ai-context/project-context.yaml;蓝图 P3-F 标 ✅

Feature-Confirmed: true
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 06:22:09 +09:00
lanrtop
d8ba9dbf40 docs(blueprint/delivery): 立卡 P3-F get_delivery_status 按 PR/分支查询(使者回传 #203)
Enterprise 2026-10-03 实测:在独立 worktree 分支开 PR 交付时,get_delivery_status
只读主工作区当前分支,PR/CI 结论与目标 PR 脱节。本卡开始执行,标 🔵。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 06:09:25 +09:00
10 changed files with 1050 additions and 78 deletions

View File

@ -459,7 +459,7 @@ modules:
area: infra
status: in_progress
progress: 14
note: "P1、P3-A、P3-S1/S2、P3-B/C/D/D1 已完成;Enterprise production 已通过 Gitea deploy context 与只读 HTTP 探针的同 SHA 双证据验收,P3-B 另在本地隔离 Docker 验证旧镜像切回后报告旧 SHA。P3-E 自 2026-09-23 起进行 2~4 周收益观察;首轮不开放炼境 SSH/部署/回滚写操作。"
note: "P1、P3-A、P3-S1/S2、P3-B/C/D/D1、P3-F 已完成;Enterprise production 已通过 Gitea deploy context 与只读 HTTP 探针的同 SHA 双证据验收,P3-B 另在本地隔离 Docker 验证旧镜像切回后报告旧 SHA。P3-F(使者回传 #203)让 get_delivery_status 可按 pr_number/branch 经 Gitea 解析 head SHA,独立 worktree 分支交付不再读错主工作区。P3-E 自 2026-09-23 起进行 2~4 周收益观察;首轮不开放炼境 SSH/部署/回滚写操作。"
position: [2000, 800]
- id: agent-client-capability-packs

View File

@ -370,6 +370,15 @@ v1 不新增 deployment history 表。最近部署时间和过程状态来自 Gi
- acceptance: "agent_verify: [HTTP API + MCP] 经用户批准合并并由 Enterprise 既有 main workflow 部署后,`GET https://api3.yljt.info/health/version` 的 revision 与目标 commit SHA 一致,`get_delivery_status(enterprise_project_id)` 返回 deployed 且同时列出 Gitea/runtime evidence;不触发炼境 SSH、部署、回滚或数据库写入。随后记录 2~4 周人工确认次数、失败定位时间与漂移发现数,未证明收益前不新增 UI。"
- progress_note: "2026-09-23 观测起点:Enterprise main SHA 3cfff458c241e419078f43a6d0d52b35397cc5fa 的公开 /health/version 与 scoped MCP 均返回同一运行 revision;MCP 列出 Gitea deploy-api success(run #531)和 http_probe healthy,deployment=deployed。观察窗为 2026-09-23 起 2~4 周;人工确认次数、失败定位时间、漂移发现数尚无完整基线数据,不据首次成功虚报收益或新增 UI。"
### ✅ P3-F. get_delivery_status 按 PR/分支查询(使者回传 #203)
- status: done
- complexity: M
- depends: P1. 交付状态与 Gitea 轻量治理投影, P3-S2. Agent PR 合并安全门禁
- files: src-tauri/src/commands/delivery_status.rs, src-tauri/src/commands/gitea.rs, src-tauri/src/mcp_tools.rs, docs/ai-context/project-context.yaml, src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md, src-tauri/resources/onboarding/.lian-jing/capability-packs.json, src-tauri/src/commands/onboarding.rs, plugins/lian-jing-codex/skills/lian-jing/SKILL.md
- source: "使者回传 #203(Enterprise 2026-10-03 实测):为避开主工作区里其他会话的在制品,在独立 worktree 分支上开 PR 交付时,get_delivery_status 只读主工作区当前分支,返回「当前分支没有开放 PR」、CI 停在主工作区 HEAD,传 pr_number 也被忽略,只能改用 git ls-remote 与 get_ci_status(commit_sha) 兜底。"
- acceptance: "agent_verify: [cargo test] 合成 Gitea 下 get_delivery_status(pr_number=N) → 经 GET /pulls/N 取 head 分支与 head SHA,pull_request.data.head_sha、ci.data.target_sha、target.head_sha 均为该 PR head SHA,review 读 /pulls/N/reviews,本地工作区不可用也不影响;branch=B → 匹配 B 的开放 PR 并以其 head SHA 查 CI,无开放 PR 时以 Gitea 远端分支 head SHA 查 CI;PR 不存在返回 not_found、远端分支不存在时 CI 为 unknown;pr_number 非正整数或 branch 非法时 MCP 直接报错、不回退主工作区;都不给时 target.mode=local_branch 保持原行为;merge_pull_request 门禁测试不变;cargo test --locked 全量通过。"
- done_note: "2026-10-03:get_delivery_status 新增可选 pr_number(优先)/branch;业务校验在 DeliveryQuery 单点实现,MCP 只解析 JSON 类型,显式非法值报错不回退。Gitea 侧拆出本地/PR/分支三种目标,PR 不存在用 404 区分为 not_found;顶层新增 target(mode/pr_number/branch/head_sha/source),pull_request.data 增 head_sha,git 字段仍反映主工作区;merge_pull_request 代码未改。合成 Gitea 测试覆盖 PR head 绑定(主工作区不可用仍成立)、PR 不存在、分支有/无开放 PR、远端分支不存在;cargo test --locked 282 passed/0 failed/5 ignored。lian-jing skill 第 6 条改为 get_delivery_status(project_id, pr_number),接入包 base-onboarding 1.8.0→1.9.0(含 TEMPLATE_FINGERPRINTS),Codex 插件 skill 同步。未经运行中的炼境实机 MCP 调用(日常实例为 master 代码,不另起实例)。"
## 迭代衡量
首个版本上线后连续观察四周:commit 至 production 部署结果可追溯的时间、production 前实际拦截问题数、失败定位与回滚耗时、PR 等待时间、Agent 重复劳动/上下文丢失次数。不能降低任一真实成本的门禁或自动化应降级或移除。

View File

@ -17,7 +17,7 @@ contracts:
- "src-tauri/src/commands/gitea.rs"
- "src-tauri/src/commands/dev_variables.rs"
- "src-tauri/src/envoy.rs"
doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
- label: 被管理项目身份上下文
paths:

View File

@ -11,7 +11,7 @@ description: Inspect an already onboarded Lian Jing project, use its existing MC
2. 仅将 `.lian-jing/context.json` 中的 `project_id` 用作炼境 MCP 调用参数。`.claude/liangjing.json` 只用于识别旧项目并提示迁移,不能作为长期身份来源。
3. 当项目已有 `lian-jing` MCP 时,可调用 `get_diagnostics`、`verify_onboarding`、`get_toolchain_status` 和 `get_delivery_status` 获取可观察信号。scoped 连接已绑定当前项目,可省略 `project_id`;仅在 initialize 报告 legacy 时显式传 canonical `project_id`,不得越界。
4. Node/pnpm 项目运行包管理命令前先读取工具链状态;`ready_managed` 时使用 `mise exec -- pnpm ...`。只有用户明确确认下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配 Codex bundled runtime 修改项目版本或 lockfile。
5. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。
5. 合并 PR 前调用 `get_delivery_status(project_id, pr_number)`,按 PR 编号读取:head 分支与 head SHA 以 Gitea 为准,不受主工作区当前分支影响,在独立 worktree 分支上交付同样适用(不传 `pr_number` 时只看主工作区当前分支)。向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。
6. 生成 CI workflow 时先调用 `plan_cicd_workflow(project_id, workflow_type)`,向用户展示目标文件、变更类型、`content_digest`、工具链来源与所需 secret 名称;只有用户明确确认这份摘要后,才以同一 digest 调用 `apply_cicd_workflow(..., confirmed=true)`。该流程只写项目 workflow,不配置 secret、不触发 CI 或部署;不得通过 MCP 传服务器地址、命令、路径或 secret 值,部署类配置先由用户在炼境 UI 保存。
7. 若缺少 MCP,依次判断 configured(scoped URL)→ reachable(炼境 loopback)→ reload(新建任务/重载客户端);停在哪层就报告哪层。缺少 canonical context 或项目登记时不得猜测 project_id、MCP URL、凭证或状态,并建议通过炼境接入或 `inject_mcp` 修复。
8. 炼境提供状态、凭证托管和受控入口;项目 Agent 仍负责代码判断、测试选择、提交分组与业务交付决策。不要把 CI 成功或部署信号表述为业务验收。

View File

@ -19,7 +19,7 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管
3. 在 Node/pnpm 项目运行包管理命令前调用 `get_toolchain_status(project_id)`;`ready_managed` 时通过 `mise exec -- pnpm ...` 执行,其他状态先报告实际版本与 executable 证据。
4. 只有用户明确确认本次下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配客户端 bundled runtime 而修改项目版本或 lockfile。
5. 交付、Gitea、CI、蓝图等平台状态优先通过 MCP 查询,不凭静态文档猜测。
6. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。
6. 合并 PR 前调用 `get_delivery_status(project_id, pr_number)`,按 PR 编号读取:head 分支与 head SHA 以 Gitea 为准,不受主工作区当前分支影响,在独立 worktree 分支上交付同样适用(不传 `pr_number` 时只看主工作区当前分支)。向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。
7. 生成 CI workflow 时先调用 `plan_cicd_workflow(project_id, workflow_type)`,向用户展示目标文件、变更类型、`content_digest`、工具链来源与所需 secret 名称;只有用户明确确认这份摘要后,才以同一 digest 调用 `apply_cicd_workflow(..., confirmed=true)`。该流程只写项目 workflow,不配置 secret、不触发 CI 或部署;不得通过 MCP 传服务器地址、命令、路径或 secret 值,部署类配置先由用户在炼境 UI 保存。
8. 需要测试账号或开发变量(登录自验、沙箱 key 等)时,先调用 `list_project_dev_variables` 查找:已配置则用 `read_project_test_account` / `read_project_dev_variable` 单项读取;没有则先登记需求——测试账号用 `register_project_test_account` 登记名称、目标系统、非生产环境与用途,开发变量用 `register_project_dev_variable` 登记名称与用途——再请用户在炼境项目 UI「开发变量」中填写。不得在对话中向用户索要明文密码,不得登记生产账号,不得把读到的值写入受 Git 跟踪的文件。读取因缺少项目认证被拒时,如实报告并改用项目原有配置方式,不得绕过认证。若 lian-jing 工具中根本没有 `list_project_dev_variables`,说明当前客户端连的是全局地址 `/mcp` 而非本项目地址,按下方 `configured` 一步处理。
9. 遇到体系级摩擦时,按下方「遇到摩擦时回传炼境」调用 `report_envoy_return`。

View File

@ -3,7 +3,7 @@
"packs": [
{
"pack_id": "base-onboarding",
"version": "1.8.0",
"version": "1.9.0",
"supported_clients": ["codex", "claude", "dsh"],
"project_artifacts": [
"AGENTS.md",

View File

@ -537,9 +537,85 @@ pub struct GitDeliveryStatus {
pub recent_commit: Option<RecentCommit>,
}
/// PR/CI/review 的查询目标(`get_delivery_status` 的可选参数)。
///
/// `pr_number` 优先于 `branch`;都不给时沿用项目主工作区的当前分支与本地 HEAD。
/// 在独立 worktree 分支上交付时,主工作区的分支与目标 PR 无关,需要显式指定。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeliveryQuery {
pr_number: Option<i64>,
branch: Option<String>,
}
impl DeliveryQuery {
/// 校验在能力本体单点实现:显式给出的非法值直接报错,绝不静默回退到主工作区。
pub fn new(pr_number: Option<i64>, branch: Option<&str>) -> Result<Self, String> {
if pr_number.is_some_and(|number| number <= 0) {
return Err("pr_number 必须为正整数".to_string());
}
if let Some(name) = branch {
validate_branch_name(name)?;
}
Ok(Self {
pr_number,
branch: branch.map(str::to_owned),
})
}
pub fn pr_number(&self) -> Option<i64> {
self.pr_number
}
pub fn branch(&self) -> Option<&str> {
self.branch.as_deref()
}
}
/// 按 git check-ref-format 的主要约束拒绝明显无效的分支名,避免拼进 Gitea API 路径时产生歧义。
fn validate_branch_name(name: &str) -> Result<(), String> {
let invalid = name.is_empty()
|| name.len() > 255
|| name.starts_with('-')
|| name.starts_with('/')
|| name.ends_with('/')
|| name.ends_with('.')
|| name.ends_with(".lock")
|| name.contains("..")
|| name.contains("//")
|| name.contains("@{")
|| name.split('/').any(|segment| segment.starts_with('.'))
|| name.chars().any(|ch| {
ch.is_control()
|| ch.is_whitespace()
|| matches!(ch, '~' | '^' | ':' | '?' | '*' | '[' | '\\')
});
if invalid {
return Err(format!("branch 不是有效的 Git 分支名:{name:?}"));
}
Ok(())
}
/// 本次 PR/CI/review 结论实际绑定的目标,便于 Agent 核对「看的是不是自己要交付的那个 PR」。
#[derive(Debug, Serialize, Clone, PartialEq)]
pub struct DeliveryTarget {
/// `local_branch`(默认:主工作区当前分支)/ `pull_request` / `branch`。
pub mode: String,
/// 指定的 PR 编号;`branch`/`local_branch` 模式下为匹配到的开放 PR 编号。
pub pr_number: Option<i64>,
/// PR 的 head 分支(以 Gitea 为准)、指定分支或主工作区当前分支。
pub branch: Option<String>,
/// CI 结论绑定的完整 commit SHA;无法确定时为 null。
pub head_sha: Option<String>,
/// `head_sha` 的来源:`gitea` 或 `local_git`。
pub source: String,
pub detail: String,
}
#[derive(Debug, Serialize, Clone, PartialEq)]
pub struct DeliveryStatus {
pub project_id: String,
pub target: DeliveryTarget,
/// 始终反映项目主工作区(本地 Git),与 `target` 是否指定无关。
pub git: GitDeliveryStatus,
pub pull_request: DeliverySignal,
pub ci: DeliverySignal,
@ -552,14 +628,80 @@ pub fn get_delivery_status(
conn: &rusqlite::Connection,
project_id: &str,
project_root: &Path,
query: &DeliveryQuery,
) -> DeliveryStatus {
get_delivery_status_with_store(
conn,
&crate::credential_store::WindowsCredentialStore,
project_id,
project_root,
query,
)
}
fn get_delivery_status_with_store(
conn: &rusqlite::Connection,
store: &impl crate::credential_store::CredentialStore,
project_id: &str,
project_root: &Path,
query: &DeliveryQuery,
) -> DeliveryStatus {
use crate::commands::gitea::DeliverySignalTarget;
let git = inspect_git_status(project_root);
let sha = git.recent_commit.as_ref().map(|commit| commit.sha.as_str());
let gitea =
crate::commands::gitea::get_delivery_signals(conn, project_id, git.branch.as_deref(), sha);
let local_sha = git.recent_commit.as_ref().map(|commit| commit.sha.as_str());
let signal_target = match (query.pr_number(), query.branch()) {
(Some(number), requested_branch) => DeliverySignalTarget::PullRequest {
number,
requested_branch,
},
(None, Some(branch)) => DeliverySignalTarget::Branch(branch),
(None, None) => DeliverySignalTarget::Local {
branch: git.branch.as_deref(),
commit_sha: local_sha,
},
};
let gitea = crate::commands::gitea::get_delivery_signals_with_store(
conn,
store,
project_id,
signal_target,
);
let matched_pr_number = gitea.pull_request.data["number"].as_i64();
let target = match signal_target {
DeliverySignalTarget::PullRequest { number, .. } => DeliveryTarget {
mode: "pull_request".into(),
pr_number: Some(number),
branch: gitea.resolved_branch.clone(),
head_sha: gitea.resolved_head_sha.clone(),
source: "gitea".into(),
detail: format!(
"按 PR #{number} 查询:head 分支与 head SHA 以 Gitea 为准,不读取主工作区当前分支"
),
},
DeliverySignalTarget::Branch(branch) => DeliveryTarget {
mode: "branch".into(),
pr_number: matched_pr_number,
branch: Some(branch.to_string()),
head_sha: gitea.resolved_head_sha.clone(),
source: "gitea".into(),
detail: format!(
"按远端分支 {branch} 查询:有开放 PR 时取其 head SHA,否则取 Gitea 上该分支 head SHA"
),
},
DeliverySignalTarget::Local { branch, commit_sha } => DeliveryTarget {
mode: "local_branch".into(),
pr_number: matched_pr_number,
branch: branch.map(str::to_owned),
head_sha: commit_sha.map(str::to_owned),
source: "local_git".into(),
detail: "未指定 pr_number/branch:按项目主工作区当前分支与本地 HEAD 查询;在独立 worktree 分支交付时请传 pr_number".into(),
},
};
DeliveryStatus {
project_id: project_id.to_string(),
target,
git,
pull_request: gitea.pull_request,
ci: gitea.ci,
@ -697,11 +839,321 @@ mod tests {
#[test]
fn no_gitea_binding_does_not_fabricate_delivery_success() {
let conn = crate::db::conn_with_schema();
let status =
get_delivery_status(&conn, "unbound", Path::new("E:/definitely-not-a-project"));
let status = get_delivery_status(
&conn,
"unbound",
Path::new("E:/definitely-not-a-project"),
&DeliveryQuery::default(),
);
assert_eq!(status.target.mode, "local_branch");
assert_eq!(status.target.source, "local_git");
assert_eq!(status.pull_request.state, "not_configured");
assert_eq!(status.ci.state, "not_configured");
assert_eq!(status.deployment.state, "not_configured");
let by_pr = get_delivery_status(
&conn,
"unbound",
Path::new("E:/definitely-not-a-project"),
&DeliveryQuery::new(Some(7), None).unwrap(),
);
assert_eq!(by_pr.target.mode, "pull_request");
assert_eq!(by_pr.target.pr_number, Some(7));
assert!(by_pr.target.head_sha.is_none(), "未绑定时不得编造 head SHA");
assert_eq!(by_pr.pull_request.state, "not_configured");
assert_eq!(by_pr.ci.state, "not_configured");
}
#[test]
fn delivery_query_rejects_invalid_explicit_targets_instead_of_falling_back() {
assert_eq!(
DeliveryQuery::new(None, None).unwrap(),
DeliveryQuery::default()
);
for number in [0, -3] {
assert!(DeliveryQuery::new(Some(number), None)
.unwrap_err()
.contains("正整数"));
}
for branch in [
"",
"-x",
"../main",
"feat/a b",
"feat//x",
"feat/",
"feat/.hidden",
"a.lock",
"x~1",
"a\\b",
"a:b",
] {
assert!(
DeliveryQuery::new(None, Some(branch)).is_err(),
"{branch:?} 应被拒绝"
);
}
let query = DeliveryQuery::new(Some(31), Some("feat/delivery-status-by-pr")).unwrap();
assert_eq!(query.pr_number(), Some(31));
assert_eq!(query.branch(), Some("feat/delivery-status-by-pr"));
}
/// 合成 Gitea:按顺序应答预期请求(断言请求行前缀),线程结束时返回实际请求行。
fn synthetic_gitea(
responses: Vec<(String, u16, String)>,
) -> (String, std::thread::JoinHandle<Vec<String>>) {
use std::io::{BufRead, BufReader, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = std::thread::spawn(move || {
let started = std::time::Instant::now();
let mut seen = Vec::new();
for (expected, status, body) in responses {
let mut stream = loop {
if let Ok((stream, _)) = listener.accept() {
break stream;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"等待请求 {expected} 超时,已收到:{seen:?}"
);
std::thread::sleep(Duration::from_millis(10));
};
// Windows 上从 nonblocking listener 接受的 socket 也可能保持 nonblocking。
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut request = String::new();
reader.read_line(&mut request).unwrap();
let mut header = String::new();
loop {
header.clear();
reader.read_line(&mut header).unwrap();
if header == "\r\n" || header.is_empty() {
break;
}
}
assert!(
request.starts_with(&format!("GET {expected}")),
"期望 GET {expected},实际 {request}"
);
seen.push(request.trim_end().to_string());
let reason = if status == 200 { "OK" } else { "Not Found" };
write!(
stream,
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.unwrap();
}
seen
});
(base_url, server)
}
fn bound_project(base_url: &str) -> rusqlite::Connection {
let conn = crate::db::conn_with_schema();
conn.execute(
"INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i1','test',?1,'synthetic-token')",
rusqlite::params![base_url],
)
.unwrap();
conn.execute(
"INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name) VALUES ('r1','i1','p1','o','r')",
[],
)
.unwrap();
conn
}
fn status_for(conn: &rusqlite::Connection, query: &DeliveryQuery) -> DeliveryStatus {
let store = crate::credential_store::MemoryCredentialStore::new();
// 主工作区不存在:证明指定 PR/分支时结论不依赖主工作区的分支与 HEAD。
get_delivery_status_with_store(
conn,
&store,
"p1",
Path::new("E:/definitely-not-a-project"),
query,
)
}
fn passed_ci(sha: &str) -> String {
serde_json::json!([{
"context": "CI / test (pull_request)",
"status": "success",
"description": "Successful",
"updated_at": "2026-10-03T08:00:00Z",
"target_url": format!("/o/r/commit/{sha}")
}])
.to_string()
}
#[test]
fn pr_number_target_binds_pr_ci_and_review_to_gitea_head_sha() {
let head = "c".repeat(40);
let pull = serde_json::json!({
"number": 7,
"state": "open",
"merged": false,
"title": "feat: worktree delivery",
"html_url": "https://gitea.invalid/o/r/pulls/7",
"head": { "ref": "feat/worktree", "sha": head },
"base": { "ref": "master" },
"updated_at": "2026-10-03T08:00:00Z"
});
let reviews = serde_json::json!([
{"user": {"login": "alice"}, "state": "APPROVED", "submitted_at": "2026-10-03T08:10:00Z"}
]);
let (base_url, server) = synthetic_gitea(vec![
("/api/v1/repos/o/r/pulls/7 ".into(), 200, pull.to_string()),
(
format!("/api/v1/repos/o/r/statuses/{head}?"),
200,
passed_ci(&head),
),
(
"/api/v1/repos/o/r/pulls/7/reviews ".into(),
200,
reviews.to_string(),
),
]);
let conn = bound_project(&base_url);
let status = status_for(
&conn,
&DeliveryQuery::new(Some(7), Some("feat/other")).unwrap(),
);
assert_eq!(server.join().unwrap().len(), 3);
assert_eq!(status.git.state, "unavailable");
assert_eq!(status.target.mode, "pull_request");
assert_eq!(status.target.pr_number, Some(7));
assert_eq!(status.target.branch.as_deref(), Some("feat/worktree"));
assert_eq!(status.target.head_sha.as_deref(), Some(head.as_str()));
assert_eq!(status.target.source, "gitea");
assert_eq!(status.pull_request.state, "open");
assert_eq!(status.pull_request.data["head_sha"], head);
assert!(
status.pull_request.detail.contains("不一致"),
"pr_number 与 branch 冲突时应提示以 pr_number 为准:{}",
status.pull_request.detail
);
assert_eq!(status.ci.state, "passed");
assert_eq!(status.ci.data["target_sha"], head);
assert_eq!(status.review.state, "approved");
assert_eq!(status.deployment.state, "not_configured");
}
#[test]
fn missing_pr_is_not_found_rather_than_main_workspace_fallback() {
let (base_url, server) = synthetic_gitea(vec![(
"/api/v1/repos/o/r/pulls/99 ".into(),
404,
r#"{"message":"The target couldn't be found."}"#.into(),
)]);
let conn = bound_project(&base_url);
let status = status_for(&conn, &DeliveryQuery::new(Some(99), None).unwrap());
assert_eq!(server.join().unwrap().len(), 1);
assert_eq!(status.pull_request.state, "not_found");
assert_eq!(status.ci.state, "unknown");
assert_eq!(status.review.state, "not_applicable");
assert_eq!(status.target.pr_number, Some(99));
assert!(status.target.head_sha.is_none());
}
#[test]
fn branch_target_prefers_open_pr_head_then_remote_branch_head() {
let pr_head = "d".repeat(40);
let pulls = serde_json::json!([
{"number": 2, "state": "open", "head": {"ref": "feat/other", "sha": "e".repeat(40)}},
{"number": 3, "state": "open", "head": {"ref": "feat/b", "sha": pr_head}}
]);
let (base_url, server) = synthetic_gitea(vec![
(
"/api/v1/repos/o/r/pulls?state=open".into(),
200,
pulls.to_string(),
),
(
format!("/api/v1/repos/o/r/statuses/{pr_head}?"),
200,
passed_ci(&pr_head),
),
(
"/api/v1/repos/o/r/pulls/3/reviews ".into(),
200,
"[]".into(),
),
]);
let conn = bound_project(&base_url);
let status = status_for(&conn, &DeliveryQuery::new(None, Some("feat/b")).unwrap());
assert_eq!(server.join().unwrap().len(), 3);
assert_eq!(status.target.mode, "branch");
assert_eq!(status.target.pr_number, Some(3));
assert_eq!(status.target.head_sha.as_deref(), Some(pr_head.as_str()));
assert_eq!(status.pull_request.state, "open");
assert_eq!(status.ci.data["target_sha"], pr_head);
assert_eq!(status.review.state, "pending");
let branch_head = "f".repeat(40);
let (base_url, server) = synthetic_gitea(vec![
(
"/api/v1/repos/o/r/pulls?state=open".into(),
200,
"[]".into(),
),
(
"/api/v1/repos/o/r/branches/feat/no-pr ".into(),
200,
serde_json::json!({"name": "feat/no-pr", "commit": {"id": branch_head}})
.to_string(),
),
(
format!("/api/v1/repos/o/r/statuses/{branch_head}?"),
200,
passed_ci(&branch_head),
),
]);
let conn = bound_project(&base_url);
let status = status_for(
&conn,
&DeliveryQuery::new(None, Some("feat/no-pr")).unwrap(),
);
assert_eq!(server.join().unwrap().len(), 3);
assert_eq!(status.target.pr_number, None);
assert_eq!(
status.target.head_sha.as_deref(),
Some(branch_head.as_str())
);
assert_eq!(status.pull_request.state, "none");
assert_eq!(status.ci.state, "passed");
assert_eq!(status.ci.data["target_sha"], branch_head);
assert_eq!(status.review.state, "not_applicable");
let (base_url, server) = synthetic_gitea(vec![
(
"/api/v1/repos/o/r/pulls?state=open".into(),
200,
"[]".into(),
),
(
"/api/v1/repos/o/r/branches/feat/unpushed ".into(),
404,
r#"{"message":"branch not found"}"#.into(),
),
]);
let conn = bound_project(&base_url);
let status = status_for(
&conn,
&DeliveryQuery::new(None, Some("feat/unpushed")).unwrap(),
);
assert_eq!(server.join().unwrap().len(), 2);
assert_eq!(status.ci.state, "unknown");
assert!(status.ci.detail.contains("不存在分支"));
assert!(status.target.head_sha.is_none());
}
const PROFILE: &str = r#"

View File

@ -476,29 +476,49 @@ pub struct GiteaDeliverySignals {
pub pull_request: crate::commands::delivery_status::DeliverySignal,
pub ci: crate::commands::delivery_status::DeliverySignal,
pub review: crate::commands::delivery_status::DeliverySignal,
/// 远端目标(指定 PR / 分支)经 Gitea 解析出的 head 分支;本地模式或解析失败时为 None。
pub resolved_branch: Option<String>,
/// 远端目标经 Gitea 解析出的完整 head SHA,CI 结论即绑定此 SHA;本地模式或解析失败时为 None。
pub resolved_head_sha: Option<String>,
}
/// `get_delivery_signals_with_store` 的查询目标。
///
/// 默认沿用本地工作区的当前分支与 HEAD;在独立 worktree 分支上交付时,主工作区的分支与
/// HEAD 与目标 PR 无关,因此可改为按 PR 编号或远端分支查询,由 Gitea 给出 head 分支与 SHA。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeliverySignalTarget<'a> {
/// 原行为:本地工作区当前分支匹配开放 PR,CI 绑定本地最近 commit SHA。
Local {
branch: Option<&'a str>,
commit_sha: Option<&'a str>,
},
/// 指定 PR:经 `GET /pulls/{number}` 取 head 分支与 head SHA;`requested_branch` 仅用于提示不一致。
PullRequest {
number: i64,
requested_branch: Option<&'a str>,
},
/// 指定远端分支:匹配该分支的开放 PR;无开放 PR 时以 Gitea 上该分支的 head SHA 查 CI。
Branch(&'a str),
}
type DeliverySignal = crate::commands::delivery_status::DeliverySignal;
fn unavailable_delivery_signals(detail: impl Into<String>) -> GiteaDeliverySignals {
fn same_state_delivery_signals(state: &str, detail: impl Into<String>) -> GiteaDeliverySignals {
let detail = detail.into();
GiteaDeliverySignals {
pull_request: DeliverySignal::new(
"unavailable",
"gitea",
detail.clone(),
serde_json::json!({}),
),
ci: DeliverySignal::new(
"unavailable",
"gitea",
detail.clone(),
serde_json::json!({}),
),
review: DeliverySignal::new("unavailable", "gitea", detail, serde_json::json!({})),
pull_request: DeliverySignal::new(state, "gitea", detail.clone(), serde_json::json!({})),
ci: DeliverySignal::new(state, "gitea", detail.clone(), serde_json::json!({})),
review: DeliverySignal::new(state, "gitea", detail, serde_json::json!({})),
resolved_branch: None,
resolved_head_sha: None,
}
}
fn unavailable_delivery_signals(detail: impl Into<String>) -> GiteaDeliverySignals {
same_state_delivery_signals("unavailable", detail)
}
fn gitea_get_json(
base_url: &str,
token: &str,
@ -513,14 +533,36 @@ fn gitea_get_json(
.map_err(|e| format!("{label}: 解析响应失败: {e}"))
}
/// 从 Gitea 拉取当前分支的 PR、当前 SHA 的 CI 以及该 PR 的 review 摘要。
/// 与 `gitea_get_json` 相同,但把 HTTP 404 作为 `Ok(None)` 返回,
/// 使「目标确实不存在」与「Gitea 不可达」成为两种可区分的信号。
fn gitea_get_json_optional(
base_url: &str,
token: &str,
path: &str,
label: &str,
) -> Result<Option<serde_json::Value>, String> {
match ureq::get(&format!("{base_url}{path}"))
.set("Authorization", &format!("token {token}"))
.call()
{
Ok(response) => response
.into_json()
.map(Some)
.map_err(|e| format!("{label}: 解析响应失败: {e}")),
Err(ureq::Error::Status(404, _)) => Ok(None),
Err(error) => Err(api_err(label, error, token)),
}
}
/// 从 Gitea 拉取目标的 PR、CI 以及该 PR 的 review 摘要(目标语义见 `DeliverySignalTarget`)。
/// 凭证存储由调用方注入(生产为 Windows 受保护存储,测试为内存存储)。
///
/// 远端不可用必须作为数据返回,以便上层继续展示本地 Git 与部署未知状态。
pub fn get_delivery_signals(
pub(crate) fn get_delivery_signals_with_store(
conn: &rusqlite::Connection,
store: &impl crate::credential_store::CredentialStore,
project_id: &str,
branch: Option<&str>,
commit_sha: Option<&str>,
target: DeliverySignalTarget<'_>,
) -> GiteaDeliverySignals {
let binding: rusqlite::Result<(String, String, String)> = conn.query_row(
"SELECT instance_id, gitea_owner, gitea_repo_name FROM gitea_repos WHERE project_id = ?1",
@ -530,40 +572,96 @@ pub fn get_delivery_signals(
let (instance_id, owner, repo) = match binding {
Ok(binding) => binding,
Err(rusqlite::Error::QueryReturnedNoRows) => {
let detail = "项目未绑定 Gitea 仓库";
return GiteaDeliverySignals {
pull_request: DeliverySignal::new(
"not_configured",
"gitea",
detail,
serde_json::json!({}),
),
ci: DeliverySignal::new("not_configured", "gitea", detail, serde_json::json!({})),
review: DeliverySignal::new(
"not_configured",
"gitea",
detail,
serde_json::json!({}),
),
};
return same_state_delivery_signals("not_configured", "项目未绑定 Gitea 仓库");
}
Err(error) => return unavailable_delivery_signals(format!("读取 Gitea 绑定失败:{error}")),
};
let (base_url, token) = match instance_auth(conn, &instance_id) {
let (base_url, token) = match instance_auth_with_store(conn, store, &instance_id) {
Ok(auth) => auth,
Err(error) => return unavailable_delivery_signals(error),
};
let remote = GiteaRepoApi {
base_url: &base_url,
token: &token,
owner: &owner,
repo: &repo,
};
match target {
DeliverySignalTarget::Local { branch, commit_sha } => {
local_delivery_signals(&remote, branch, commit_sha)
}
DeliverySignalTarget::PullRequest {
number,
requested_branch,
} => pull_request_delivery_signals(&remote, number, requested_branch),
DeliverySignalTarget::Branch(branch) => branch_delivery_signals(&remote, branch),
}
}
let pull_path =
format!("/api/v1/repos/{owner}/{repo}/pulls?state=open&limit=50&type=pullrequest");
let pull_requests = match gitea_get_json(&base_url, &token, &pull_path, "读取 Gitea PR 失败")
{
/// 一次交付状态查询内共享的 Gitea 仓库访问参数。
struct GiteaRepoApi<'a> {
base_url: &'a str,
token: &'a str,
owner: &'a str,
repo: &'a str,
}
impl GiteaRepoApi<'_> {
fn path(&self, suffix: &str) -> String {
format!("/api/v1/repos/{}/{}{suffix}", self.owner, self.repo)
}
fn get(&self, suffix: &str, label: &str) -> Result<serde_json::Value, String> {
gitea_get_json(self.base_url, self.token, &self.path(suffix), label)
}
fn get_optional(&self, suffix: &str, label: &str) -> Result<Option<serde_json::Value>, String> {
gitea_get_json_optional(self.base_url, self.token, &self.path(suffix), label)
}
fn open_pull_requests(&self) -> Result<serde_json::Value, String> {
let pulls = self.get(
"/pulls?state=open&limit=50&type=pullrequest",
"读取 Gitea PR 失败",
)?;
if pulls.is_array() {
Ok(pulls)
} else {
Err("Gitea PR 响应不是数组".to_string())
}
}
fn ci_for_sha(&self, sha: &str) -> DeliverySignal {
match self.get(
&format!("/statuses/{sha}?sort=recentupdate&limit=50"),
"读取 Gitea CI 状态失败",
) {
Ok(statuses) => summarize_ci_statuses(&statuses, Some(sha)),
Err(error) => DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({})),
}
}
fn review_for_pull_request(&self, number: i64) -> DeliverySignal {
match self.get(
&format!("/pulls/{number}/reviews"),
"读取 Gitea review 状态失败",
) {
Ok(reviews) => summarize_reviews(&reviews),
Err(error) => DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({})),
}
}
}
/// 原行为:当前分支取自本地工作区,CI 绑定本地最近 commit SHA。
fn local_delivery_signals(
remote: &GiteaRepoApi<'_>,
branch: Option<&str>,
commit_sha: Option<&str>,
) -> GiteaDeliverySignals {
let pull_requests = match remote.open_pull_requests() {
Ok(value) => value,
Err(error) => return unavailable_delivery_signals(error),
};
if !pull_requests.is_array() {
return unavailable_delivery_signals("Gitea PR 响应不是数组");
}
let matched_pr = branch.and_then(|name| select_open_pull_request(&pull_requests, name));
let pull_request = match (branch, matched_pr) {
@ -588,16 +686,7 @@ pub fn get_delivery_signals(
};
let ci = match commit_sha {
Some(sha) => {
let path =
format!("/api/v1/repos/{owner}/{repo}/statuses/{sha}?sort=recentupdate&limit=50");
match gitea_get_json(&base_url, &token, &path, "读取 Gitea CI 状态失败") {
Ok(statuses) => summarize_ci_statuses(&statuses, Some(sha)),
Err(error) => {
DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({}))
}
}
}
Some(sha) => remote.ci_for_sha(sha),
None => DeliverySignal::new(
"unknown",
"gitea",
@ -607,15 +696,7 @@ pub fn get_delivery_signals(
};
let review = match matched_pr.and_then(|pr| pr["number"].as_i64()) {
Some(number) => {
let path = format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews");
match gitea_get_json(&base_url, &token, &path, "读取 Gitea review 状态失败") {
Ok(reviews) => summarize_reviews(&reviews),
Err(error) => {
DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({}))
}
}
}
Some(number) => remote.review_for_pull_request(number),
None if branch.is_some() => DeliverySignal::new(
"not_applicable",
"gitea",
@ -634,6 +715,206 @@ pub fn get_delivery_signals(
pull_request,
ci,
review,
resolved_branch: None,
resolved_head_sha: None,
}
}
/// 指定 PR:head 分支与 head SHA 均以 Gitea 为准,CI 与 review 绑定该 PR。
fn pull_request_delivery_signals(
remote: &GiteaRepoApi<'_>,
number: i64,
requested_branch: Option<&str>,
) -> GiteaDeliverySignals {
let pr = match remote.get_optional(&format!("/pulls/{number}"), "读取 Gitea PR 失败") {
Ok(Some(pr)) => pr,
Ok(None) => {
let detail = format!("Gitea 上不存在 PR #{number}");
return GiteaDeliverySignals {
pull_request: DeliverySignal::new(
"not_found",
"gitea",
detail.clone(),
serde_json::json!({ "number": number }),
),
ci: DeliverySignal::new(
"unknown",
"gitea",
format!("{detail},无法确定 head SHA,未查询 CI"),
serde_json::json!({}),
),
review: DeliverySignal::new(
"not_applicable",
"gitea",
format!("{detail},因此无 review 状态"),
serde_json::json!({}),
),
resolved_branch: None,
resolved_head_sha: None,
};
}
Err(error) => return unavailable_delivery_signals(error),
};
let head_branch = pr["head"]["ref"]
.as_str()
.filter(|value| !value.is_empty())
.map(str::to_owned);
let head_sha = pr["head"]["sha"]
.as_str()
.filter(|value| is_full_git_sha(value))
.map(str::to_owned);
let state = pull_request_state(&pr);
let mut detail = match state {
"open" => format!("PR #{number} 开放中"),
"merged" => format!("PR #{number} 已合并"),
"closed" => format!("PR #{number} 已关闭且未合并"),
_ => format!("Gitea 未返回 PR #{number} 的可识别状态"),
};
if let (Some(requested), Some(actual)) = (requested_branch, head_branch.as_deref()) {
if requested != actual {
detail.push_str(&format!(
";传入 branch={requested} 与该 PR 的 head 分支 {actual} 不一致,已以 pr_number 为准"
));
}
}
let pull_request = DeliverySignal::new(state, "gitea", detail, pull_request_summary(&pr));
let ci = match head_sha.as_deref() {
Some(sha) => remote.ci_for_sha(sha),
None => DeliverySignal::new(
"unknown",
"gitea",
format!("Gitea 未返回 PR #{number} 的完整 head SHA,无法查询 CI"),
serde_json::json!({}),
),
};
let review = remote.review_for_pull_request(number);
GiteaDeliverySignals {
pull_request,
ci,
review,
resolved_branch: head_branch,
resolved_head_sha: head_sha,
}
}
/// 指定远端分支:有开放 PR 时以其 head SHA 为准;没有时读取 Gitea 上该分支的 head SHA。
fn branch_delivery_signals(remote: &GiteaRepoApi<'_>, branch: &str) -> GiteaDeliverySignals {
let pull_requests = match remote.open_pull_requests() {
Ok(value) => value,
Err(error) => return unavailable_delivery_signals(error),
};
if let Some(pr) = select_open_pull_request(&pull_requests, branch) {
let number = pr["number"].as_i64();
let head_sha = pr["head"]["sha"]
.as_str()
.filter(|value| is_full_git_sha(value))
.map(str::to_owned);
let ci = match head_sha.as_deref() {
Some(sha) => remote.ci_for_sha(sha),
None => DeliverySignal::new(
"unknown",
"gitea",
format!("Gitea 未返回分支 {branch} 开放 PR 的完整 head SHA,无法查询 CI"),
serde_json::json!({}),
),
};
let review = match number {
Some(number) => remote.review_for_pull_request(number),
None => DeliverySignal::new(
"unknown",
"gitea",
"Gitea 未返回 PR 编号,未查询 review",
serde_json::json!({}),
),
};
return GiteaDeliverySignals {
pull_request: DeliverySignal::new(
"open",
"gitea",
format!("分支 {branch} 存在开放 PR"),
pull_request_summary(pr),
),
ci,
review,
resolved_branch: Some(branch.to_string()),
resolved_head_sha: head_sha,
};
}
let branch_path = format!("/branches/{}", encode_branch_path(branch));
let (ci, head_sha) = match remote.get_optional(&branch_path, "读取 Gitea 分支失败") {
Ok(Some(response)) => {
let head_sha = response["commit"]["id"]
.as_str()
.or_else(|| response["commit"]["sha"].as_str())
.filter(|value| is_full_git_sha(value))
.map(str::to_owned);
match head_sha {
Some(sha) => (remote.ci_for_sha(&sha), Some(sha)),
None => (
DeliverySignal::new(
"unknown",
"gitea",
format!("Gitea 未返回分支 {branch} 的完整 head SHA,无法查询 CI"),
serde_json::json!({}),
),
None,
),
}
}
Ok(None) => (
DeliverySignal::new(
"unknown",
"gitea",
format!("Gitea 上不存在分支 {branch}(可能尚未 push),无法查询 CI"),
serde_json::json!({}),
),
None,
),
Err(error) => (
DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({})),
None,
),
};
GiteaDeliverySignals {
pull_request: DeliverySignal::new(
"none",
"gitea",
format!("分支 {branch} 没有开放 PR"),
serde_json::json!({}),
),
ci,
review: DeliverySignal::new(
"not_applicable",
"gitea",
format!("分支 {branch} 没有开放 PR,因此无 review 状态"),
serde_json::json!({}),
),
resolved_branch: Some(branch.to_string()),
resolved_head_sha: head_sha,
}
}
/// Gitea 的 `/branches/*` 路由按原样接受分支名中的 `/`,因此逐段编码、保留分隔符。
fn encode_branch_path(branch: &str) -> String {
branch
.split('/')
.map(|segment| urlencoding::encode(segment).into_owned())
.collect::<Vec<_>>()
.join("/")
}
/// Gitea PR 状态归一:`merged` 优先于 `closed`,未知值不猜测。
fn pull_request_state(pr: &serde_json::Value) -> &'static str {
if pr["merged"].as_bool() == Some(true) {
return "merged";
}
match pr["state"].as_str() {
Some("open") => "open",
Some("closed") => "closed",
_ => "unknown",
}
}
@ -656,6 +937,7 @@ fn pull_request_summary(pr: &serde_json::Value) -> serde_json::Value {
"title": pr["title"],
"url": pr["html_url"],
"head": pr["head"]["ref"],
"head_sha": pr["head"]["sha"],
"base": pr["base"]["ref"],
"updated_at": pr["updated_at"],
})
@ -3012,6 +3294,28 @@ mod tests {
assert!(select_open_pull_request(&pulls, "main").is_none());
}
#[test]
fn delivery_pr_state_prefers_merged_and_never_guesses_unknown() {
let state = |value: serde_json::Value| pull_request_state(&value);
assert_eq!(state(serde_json::json!({"state": "open"})), "open");
assert_eq!(
state(serde_json::json!({"state": "closed", "merged": true})),
"merged"
);
assert_eq!(
state(serde_json::json!({"state": "closed", "merged": false})),
"closed"
);
assert_eq!(state(serde_json::json!({})), "unknown");
}
#[test]
fn branch_path_keeps_separators_and_encodes_segments() {
assert_eq!(encode_branch_path("feat/delivery"), "feat/delivery");
assert_eq!(encode_branch_path("feat/a#b"), "feat/a%23b");
assert_eq!(encode_branch_path("feat/交付"), "feat/%E4%BA%A4%E4%BB%98");
}
fn merge_gate_fixture() -> (serde_json::Value, DeliverySignal, DeliverySignal) {
let sha = "0123456789abcdef0123456789abcdef01234567";
let pull_request = serde_json::json!({

View File

@ -26,7 +26,7 @@ const TMPL_CAPABILITY_PACKS: &str =
const TMPL_MISE: &str = include_str!("../../resources/onboarding/mise.toml.tmpl");
const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1;
const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1;
const BASE_ONBOARDING_PACK_VERSION: &str = "1.8.0";
const BASE_ONBOARDING_PACK_VERSION: &str = "1.9.0";
const TOOLCHAIN_PACK_VERSION: &str = "1.0.0";
/// 单个模板的分发结果。
@ -1947,8 +1947,8 @@ mod tests {
const TEMPLATE_FINGERPRINTS: [(&str, &str, &str); 3] = [
(
"resources/onboarding/**",
"1.8.0",
"fac04a0bd9f3a443349fdd50d6eef22147c56d0843e1769a60b9bbddc372d091",
"1.9.0",
"9e719b041f90f084d412e7d5e715bbf72950302ab3f0138ea52841ffe2ccec8e",
),
(
"AGENTS.md.tmpl#ONBOARDING_MANAGED",

View File

@ -200,11 +200,13 @@ pub fn tools_list_result() -> Value {
},
{
"name": "get_delivery_status",
"description": "只读读取项目交付信号:本地 Git/PR/CI/review,以及项目声明的 production Gitea 部署 context 与公开 HTTP 版本探针。仅当部署成功、探针健康且运行 SHA 与 Gitea 默认分支目标 SHA 完全相同才返回 deployed;失败、跳过部署、部署成功后的漂移、未配置或来源不可达分别保留 failed/skipped/drifted/not_configured/unavailable,不替项目作发布决定。",
"description": "只读读取项目交付信号:本地 Git/PR/CI/review,以及项目声明的 production Gitea 部署 context 与公开 HTTP 版本探针。PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;在独立 worktree 分支上交付时传 pr_number(优先)或 branch,改由 Gitea 给出 head 分支与 head SHA,结论绑定该 SHA,返回的 target 字段标明实际查询目标。仅当部署成功、探针健康且运行 SHA 与 Gitea 默认分支目标 SHA 完全相同才返回 deployed;失败、跳过部署、部署成功后的漂移、未配置或来源不可达分别保留 failed/skipped/drifted/not_configured/unavailable,不替项目作发布决定。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_all_projects 的返回结果)" }
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_all_projects 的返回结果)" },
"pr_number": { "type": "integer", "minimum": 1, "description": "可选,优先于 branch:按该 PR 查询。经 Gitea 读取该 PR 的 head 分支与 head SHA,CI 与 review 均绑定该 PR;合并前核对 PR 编号与 head SHA 时使用。" },
"branch": { "type": "string", "description": "可选:按该远端分支查询。有开放 PR 时以其 head SHA 为准,没有时取 Gitea 上该分支的 head SHA 查询 CI。不传 pr_number/branch 时沿用项目主工作区当前分支。" }
},
"required": ["project_id"]
}
@ -625,11 +627,57 @@ fn apply_tool_annotations(result: &mut Value) {
}
}
/// 只做 JSON 类型解析;取值语义(正整数、分支名格式)由能力本体 `DeliveryQuery::new` 单点校验。
/// 缺省或 null 视为未指定;显式给出但类型不对时报错,不静默回退到主工作区当前分支。
fn parse_delivery_query(
args: &Value,
) -> Result<crate::commands::delivery_status::DeliveryQuery, String> {
let pr_number = match args.get("pr_number") {
None | Some(Value::Null) => None,
Some(value) => Some(
value
.as_i64()
.ok_or_else(|| "pr_number 必须为正整数".to_string())?,
),
};
let branch = match args.get("branch") {
None | Some(Value::Null) => None,
Some(value) => Some(
value
.as_str()
.ok_or_else(|| "branch 必须为字符串".to_string())?,
),
};
crate::commands::delivery_status::DeliveryQuery::new(pr_number, branch)
}
fn delivery_target_summary(target: &Value) -> String {
let head = target["head_sha"].as_str().unwrap_or("unknown");
match target["mode"].as_str() {
Some("pull_request") => format!(
"PR #{}(head 分支 {},head SHA {head})",
target["pr_number"]
.as_i64()
.map_or("?".to_string(), |n| n.to_string()),
target["branch"].as_str().unwrap_or("unknown")
),
Some("branch") => format!(
"远端分支 {}(head SHA {head})",
target["branch"].as_str().unwrap_or("?")
),
_ => format!(
"主工作区当前分支 {}(本地 HEAD {head})",
target["branch"].as_str().unwrap_or("unknown")
),
}
}
fn structured_tool_summary(tool_name: &str, value: &Value) -> String {
match tool_name {
"get_delivery_status" => format!(
"交付状态:project_id={},Git={},PR={},CI={},review={},deployment={}",
"交付状态:project_id={},目标={},Git={},PR={},CI={},review={},deployment={}",
value["project_id"].as_str().unwrap_or("?"),
delivery_target_summary(&value["target"]),
value["git"]["state"].as_str().unwrap_or("unknown"),
value["pull_request"]["state"].as_str().unwrap_or("unknown"),
value["ci"]["state"].as_str().unwrap_or("unknown"),
@ -904,9 +952,12 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
if pid.is_empty() {
return Err("project_id 不能为空".to_string());
}
let query = parse_delivery_query(&args)?;
let root = resolve_project_root(pid)?;
let conn = db::pool().get().map_err(|e| e.to_string())?;
let status = crate::commands::delivery_status::get_delivery_status(&conn, pid, &root);
let status = crate::commands::delivery_status::get_delivery_status(
&conn, pid, &root, &query,
);
serde_json::to_string_pretty(&status).map_err(|e| e.to_string())
}
"get_toolchain_status" => {
@ -2534,6 +2585,162 @@ mod tests {
.contains("明确确认"));
}
#[test]
fn delivery_status_schema_offers_optional_pr_and_branch_targets() {
let result = tools_list_result();
let delivery = result["tools"]
.as_array()
.unwrap()
.iter()
.find(|tool| tool["name"] == "get_delivery_status")
.unwrap()
.clone();
let properties = &delivery["inputSchema"]["properties"];
assert_eq!(properties["pr_number"]["type"], "integer");
assert_eq!(properties["branch"]["type"], "string");
let required = delivery["inputSchema"]["required"].as_array().unwrap();
assert!(!required
.iter()
.any(|field| field == "pr_number" || field == "branch"));
assert!(delivery["description"]
.as_str()
.unwrap()
.contains("pr_number"));
let scoped = tools_list_result_for_project("p1");
let scoped_delivery = scoped["tools"]
.as_array()
.unwrap()
.iter()
.find(|tool| tool["name"] == "get_delivery_status")
.unwrap()
.clone();
assert_eq!(
scoped_delivery["inputSchema"]["properties"]["pr_number"]["type"],
"integer"
);
}
#[tokio::test]
async fn delivery_status_rejects_invalid_target_before_io() {
for (arguments, expected) in [
(
json!({"project_id": "p1", "pr_number": 0}),
"pr_number 必须为正整数",
),
(
json!({"project_id": "p1", "pr_number": "31"}),
"pr_number 必须为正整数",
),
(
json!({"project_id": "p1", "branch": 5}),
"branch 必须为字符串",
),
(
json!({"project_id": "p1", "branch": "../main"}),
"branch 不是有效的 Git 分支名",
),
] {
let result = tools_call(Some(&json!({
"name": "get_delivery_status",
"arguments": arguments
})))
.await;
assert_eq!(result["isError"], true, "{arguments}");
let text = result["content"][0]["text"].as_str().unwrap_or_default();
assert!(text.contains(expected), "{arguments} → {text}");
}
}
#[test]
fn delivery_summary_names_the_queried_target_and_full_head_sha() {
let sha = "0123456789abcdef0123456789abcdef01234567";
let response = tool_success_response(
"get_delivery_status",
json!({
"project_id": "p1",
"target": {
"mode": "pull_request",
"pr_number": 31,
"branch": "feat/delivery-status-by-pr",
"head_sha": sha,
"source": "gitea"
},
"git": { "state": "dirty" },
"pull_request": { "state": "open" },
"ci": { "state": "passed" },
"review": { "state": "pending" },
"deployment": { "state": "not_configured" }
})
.to_string(),
);
let text = response["content"][0]["text"].as_str().unwrap();
assert!(text.contains("PR #31"), "{text}");
assert!(text.contains(sha), "{text}");
assert!(text.contains("feat/delivery-status-by-pr"), "{text}");
}
/// 使者回传 #203 的症状正是「传 pr_number 也不起作用」:经 scoped dispatch 断言
/// pr_number/branch 真正到达能力本体(未绑定 Gitea,不发网络请求)。
#[tokio::test]
async fn delivery_status_dispatch_forwards_pr_and_branch_targets() {
db::init_test_pool();
let base = temp_base("delivery_target_dispatch");
{
let conn = db::pool().get().unwrap();
conn.execute(
"INSERT OR IGNORE INTO project_profiles (id, name) VALUES ('delivery-target-profile', 'Delivery target fixture')",
[],
)
.unwrap();
conn.execute(
"INSERT OR IGNORE INTO project_workspaces (id, profile_id, win_path) VALUES ('delivery-target', 'delivery-target-profile', ?1)",
[base.to_str().unwrap()],
)
.unwrap();
}
let target_of = |result: &Value| {
assert_ne!(result["isError"], true, "{result}");
result["structuredContent"]["target"].clone()
};
let by_pr = tools_call_for_project(
Some(&json!({"name": "get_delivery_status", "arguments": {
"pr_number": 31, "branch": "feat/worktree"
}})),
"delivery-target",
)
.await;
let target = target_of(&by_pr);
assert_eq!(target["mode"], "pull_request", "{by_pr}");
assert_eq!(target["pr_number"], 31, "{by_pr}");
assert_eq!(
by_pr["structuredContent"]["pull_request"]["state"],
"not_configured"
);
let by_branch = tools_call_for_project(
Some(&json!({"name": "get_delivery_status", "arguments": {
"branch": "feat/worktree"
}})),
"delivery-target",
)
.await;
let target = target_of(&by_branch);
assert_eq!(target["mode"], "branch", "{by_branch}");
assert_eq!(target["branch"], "feat/worktree", "{by_branch}");
let default = tools_call_for_project(
Some(&json!({"name": "get_delivery_status", "arguments": {
"pr_number": null, "branch": null
}})),
"delivery-target",
)
.await;
assert_eq!(target_of(&default)["mode"], "local_branch", "{default}");
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn delivery_status_tool_requires_project_id() {
let result = tools_call(Some(&json!({