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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,13 @@ assert.deepEqual(projectEditableCapabilityConfiguration(periodicReportEditor, {
assert.deepEqual(parseEditableCapabilityJson(periodicReportEditor, JSON.stringify({ schedule })), { schedule });
assert.deepEqual(projectEditableCapabilityConfiguration(periodicReportEditor, { schedule: null }, { schedule }), { schedule: null },
"explicit nullable clear must not restore the inherited schedule on editor mode changes");

assert.deepEqual(
projectEditableCapabilityConfiguration(
{ fields: [{ key: "wait_for_ci" }, { key: "review_priority" }] },
{ wait_for_ci: false, review_priority: "other-developers-first", schema_version: "pull_request_review_goal_configuration_v0" },
{ wait_for_ci: true },
),
{ wait_for_ci: false, review_priority: "other-developers-first" },
"an explicit Goal CI opt-out must survive default projection and omit envelope fields",
);
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const fieldCopy: Record<WorkspaceLocale, FieldCopy> = {
max_children: { label: "Maximum children", description: "Hard upper bound for concurrently delegated child work." },
profile: { label: "Planner profile", description: "Select one registered Explore Harness profile." },
profile_preset: { label: "Report profile", description: "Capability-owned report profile, such as weekly-progress." },
wait_for_ci: { label: "Wait for CI", description: "Disable to use local validation without querying or waiting for CI. Merge authority is unchanged." },
review_priority: { label: "Review priority", description: "Choose whether other developers' PRs or the authenticated reviewer's own PRs are ranked first." },
route_ref: { label: "Goal Channel route", description: "Public route alias only; credentials and provider identifiers stay outside this form." },
safe_fix: { label: "Allow one bounded safe-fix pass" },
Expand All @@ -149,6 +150,7 @@ const fieldCopy: Record<WorkspaceLocale, FieldCopy> = {
max_children: { label: "最大子 Agent 数", description: "可同时委派的子任务硬上限。" },
profile: { label: "规划 Profile", description: "选择一个已注册的 Explore Harness profile。" },
profile_preset: { label: "报告 Profile", description: "由该能力管理的报告 profile,例如 weekly-progress。" },
wait_for_ci: { label: "等待 CI", description: "关闭后使用本地验证,不查询或等待 CI;不改变合并权限。" },
review_priority: { label: "审阅优先级", description: "选择先排其他开发者的 PR,还是先排当前已认证审阅者自己的 PR。" },
route_ref: { label: "Goal Channel 路由", description: "只填写公开 route alias;凭据与 Provider 标识不会进入此表单。" },
safe_fix: { label: "允许一次有界安全修复" },
Expand Down
5 changes: 3 additions & 2 deletions loopx/capabilities/configuration_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,10 @@ def capability_configuration_editor(
],
},
"pull_request_review": {
"supported_scopes": ["machine"],
"writable_scopes": ["machine"],
"supported_scopes": ["machine", "goal"],
"writable_scopes": ["machine", "goal"],
"fields": [
_field("wait_for_ci", "Wait for CI", "boolean", description="When disabled, use local validation without querying or waiting for CI. This grants no merge authority."),
_field(
"review_priority",
"Review priority",
Expand Down
3 changes: 3 additions & 0 deletions loopx/capabilities/machine_configuration/builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ def builtin_machine_inheritable_goal_overrides(
configuration_summary as cadence_summary,
)

from ..pr_review_queue.goal_configuration import configuration_summary as pr_review_summary

summaries = {
"pull_request_review": pr_review_summary(goal),
"change_quality_qualification": change_quality_summary(goal),
"todo_replan_cadence": cadence_summary(goal),
}
Expand Down
32 changes: 31 additions & 1 deletion loopx/capabilities/pr_review_queue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ absolute paths, private source bodies, or hidden CI artifacts.
},
"risk_notes": [],
"evidence_commands": [
"gh pr view 773 --json title,body,files,commits,statusCheckRollup,headRefOid,updatedAt",
"gh pr view 773 --json title,body,files,commits,headRefOid,updatedAt",
"gh pr diff 773 --name-only",
"gh pr diff 773 --patch",
"gh pr view 773 --json headRefOid,updatedAt"
Expand Down Expand Up @@ -782,3 +782,33 @@ A first implementation is acceptable when:
- live GitHub reads and fixture-based smokes share the same schema;
- no raw logs, private payloads, credentials, local paths, or private source
bodies are recorded.

## Configure CI waiting / 配置是否等待 CI

`pull_request_review.wait_for_ci` defaults to `true`. Machine defaults use the
existing capability editor. A Goal may override the complete review namespace;
clearing that override restores live machine defaults. Local required validation
and exact-head review/thread gates apply in both modes. Disabling CI waiting
also removes CI requests and waiting instructions; legacy supplied summaries
are diagnostic only. It grants no publication, merge, or admin-bypass authority.

```bash
loopx configure-goal --goal-id GOAL --no-pr-review-wait-for-ci --execute
loopx configure-goal --goal-id GOAL
loopx pr-review --goal-id GOAL --state all --format json
loopx pr-review --goal-id GOAL --check-merge-readiness NUMBER@HEAD_OID --format json
loopx configure-goal --goal-id GOAL --clear-pr-review-configuration --execute
```

The Dashboard capability editor exposes **Wait for CI** in machine and Goal
scopes. Save a Goal override to affect only that Goal; use inherit/reset to
restore machine defaults. The CLI packet echoes the resolved configuration.
A Goal namespace is atomic (including review priority); partial updates retain
its existing values, and a new namespace uses capability defaults.

`wait_for_ci` 默认开启,保留既有 CI 验证行为。Dashboard 的机器/目标 capability
编辑器提供“等待 CI”开关。以上命令只关闭指定 Goal 的等待,读取配置和评审载荷
可确认生效;清除完整目标覆盖后恢复机器默认。目标覆盖是完整 namespace(包括
审阅优先级),部分修改保留既有目标值,新覆盖使用 capability 默认值。关闭时不
查询、轮询或等待 CI;本地必需验证、当前提交评审、评论及权限检查仍然适用。
GitHub `BLOCKED` 只提示另需管理员授权,不授予合并权限。
4 changes: 2 additions & 2 deletions loopx/capabilities/pr_review_queue/catalog_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"commands": [
{
"command": "loopx pr-review --repo <owner/repo> --check-merge-readiness NUMBER@HEAD_OID --format json",
"purpose": "Fail closed on exact-head, approval-body, check, thread, or merge-state drift immediately before merge.",
"purpose": "Fail closed on exact-head, approval-body, configured CI, thread, or merge-state drift immediately before merge.",
"write_boundary": "live public GitHub read only; does not approve, merge, bypass policy, or grant merge authority",
},
{
Expand Down Expand Up @@ -151,7 +151,7 @@
"Only rows with a non-null review_action_kind enter review_sequence and carry review plans, templates, or evidence commands; valid exact-head conclusions remain artifact-free inventory-only rows, and only --fresh-audit-exact-head NUMBER@HEAD_OID can explicitly reopen one.",
"Todo prose, monitor notes, and one-off author filters are not scheduling authority.",
"A complete exact-head conclusion requires the five Chinese sections, a state-aligned English verdict, and formal state or the verdict-specific titled author-owned fallback.",
"Every merge must rerun the read-only merge-readiness gate for the reviewed exact head; admin bypass cannot override stale review text, red or pending checks, incomplete thread evidence, or head drift.",
"Every merge must rerun the read-only merge-readiness gate for the reviewed exact head; admin bypass cannot override stale review text, required CI when wait_for_ci is true, incomplete thread evidence, or head drift.",
"One observation emits at most one exact-head advancement Todo preview; unchanged observations replay it until explicit durable Todo-projection ACK, then rotate across acknowledged exact heads.",
"The capability reuses the existing pr-review GitHub scan and normalized packet; review bodies are inspected for format but never emitted or checkpointed.",
"Candidate selection grants no GitHub review, comment, push, merge, quota, or Todo-write authority; those remain with their existing policy surfaces.",
Expand Down
11 changes: 7 additions & 4 deletions loopx/capabilities/pr_review_queue/github_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
"createdAt",
"commits",
"reviews",
"statusCheckRollup",
)


Expand Down Expand Up @@ -91,9 +90,11 @@ def attach_pr_review_details(
repository: str | None,
cwd: Path | None = None,
run_gh_json: GitHubJsonRunner = run_gh_json,
wait_for_ci: bool = True,
) -> bool:
"""Attach complete per-PR details after the lightweight list scan."""

detail_fields = DETAIL_FIELDS + (("statusCheckRollup",) if wait_for_ci else ())
number = str(row.get("number") or "").strip()
if not number or not repository:
return False
Expand All @@ -104,7 +105,7 @@ def attach_pr_review_details(
"view",
number,
"--json",
",".join(DETAIL_FIELDS),
",".join(detail_fields),
"--repo",
repository,
],
Expand All @@ -117,7 +118,7 @@ def attach_pr_review_details(
except (KeyError, TypeError, ValueError):
return False
if not isinstance(details, dict) or any(
key not in details for key in DETAIL_FIELDS
key not in details for key in detail_fields
):
return False
detail_files = details["files"]
Expand All @@ -134,7 +135,7 @@ def attach_pr_review_details(
if detail_files is None:
return False
details["files"] = detail_files
for key in DETAIL_FIELDS:
for key in detail_fields:
row[key] = details[key]
return True

Expand All @@ -149,6 +150,7 @@ def attach_pr_review_details_concurrently(
cwd: Path | None = None,
attach: Callable[..., bool] = attach_pr_review_details,
run_gh_json: GitHubJsonRunner = run_gh_json,
wait_for_ci: bool = True,
) -> list[bool]:
"""Read per-PR details concurrently while preserving queue order."""

Expand All @@ -162,6 +164,7 @@ def read(row: dict[str, Any]) -> bool:
repository=repository,
cwd=cwd,
run_gh_json=run_gh_json,
**({"wait_for_ci": False} if not wait_for_ci else {}),
)

with ThreadPoolExecutor(max_workers=worker_count) as executor:
Expand Down
91 changes: 91 additions & 0 deletions loopx/capabilities/pr_review_queue/goal_configuration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Goal overrides for the existing pull-request-review configuration owner."""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any

from .machine_defaults import (
normalize_pull_request_review_machine_defaults,
PULL_REQUEST_REVIEW_MACHINE_DEFAULTS_SCHEMA,
)
from .scheduling import normalize_review_priority

GOAL_CONFIGURATION_SCHEMA = "pull_request_review_goal_configuration_v0"


def configuration_summary(goal: Mapping[str, Any]) -> dict[str, Any] | None:
control = goal.get("control_plane", {})
raw = control.get("pull_request_review") if isinstance(control, Mapping) else None
if raw is None:
return None
if not isinstance(raw, Mapping):
raise TypeError("pull_request_review Goal configuration must be an object")
if raw.get("schema_version") != GOAL_CONFIGURATION_SCHEMA:
raise ValueError(
"pull_request_review Goal configuration has an unsupported schema"
)
return {
"wait_for_ci": True,
"review_priority": "other-developers-first",
**normalize_configuration(
{k: v for k, v in raw.items() if k != "schema_version"}
),
}


def normalize_configuration(raw: Mapping[str, Any]) -> dict[str, Any]:
unknown = set(raw) - {"wait_for_ci", "review_priority"}
if unknown:
raise ValueError(
"unsupported pull_request_review fields: " + ", ".join(sorted(unknown))
)
result = dict(raw)
if "wait_for_ci" in result and type(result["wait_for_ci"]) is not bool:
raise TypeError("pull_request_review.wait_for_ci must be a boolean")
if "review_priority" in result:
result["review_priority"] = normalize_review_priority(
result["review_priority"]
).value
return result


def resolve_configuration(
goal: Mapping[str, Any] | None = None,
machine_configuration: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
raw = (machine_configuration or {}).get("namespaces", {}).get("pull_request_review")
config = normalize_pull_request_review_machine_defaults(
raw or {"schema_version": PULL_REQUEST_REVIEW_MACHINE_DEFAULTS_SCHEMA}
)
config.pop("schema_version")
override = configuration_summary(goal or {})
return override if override is not None else config


def apply_change(
goal: dict[str, Any], configuration: Mapping[str, Any] | None, *, clear: bool
) -> None:
if clear and configuration is not None:
raise ValueError(
"clear PR review configuration cannot be combined with settings"
)
if not clear and configuration is None:
return
control = dict(goal.get("control_plane") or {})
if clear:
control.pop("pull_request_review", None)
else:
current = configuration_summary(goal) or {
"wait_for_ci": True,
"review_priority": "other-developers-first",
}
current.update(normalize_configuration(configuration))
control["pull_request_review"] = {
"schema_version": GOAL_CONFIGURATION_SCHEMA,
**current,
}
if control:
goal["control_plane"] = control
else:
goal.pop("control_plane", None)
14 changes: 10 additions & 4 deletions loopx/capabilities/pr_review_queue/machine_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
def normalize_pull_request_review_machine_defaults(
raw: Mapping[str, Any],
) -> dict[str, Any]:
unknown = sorted(set(raw) - {"schema_version", "review_priority"})
unknown = sorted(set(raw) - {"schema_version", "review_priority", "wait_for_ci"})
if unknown:
raise ValueError(
"pull_request_review contains unsupported fields: "
Expand All @@ -32,7 +32,11 @@ def normalize_pull_request_review_machine_defaults(
"pull_request_review must use "
+ PULL_REQUEST_REVIEW_MACHINE_DEFAULTS_SCHEMA
)
wait_for_ci = raw.get("wait_for_ci", True)
if type(wait_for_ci) is not bool:
raise TypeError("pull_request_review.wait_for_ci must be a boolean")
return {
"wait_for_ci": wait_for_ci,
"schema_version": PULL_REQUEST_REVIEW_MACHINE_DEFAULTS_SCHEMA,
"review_priority": normalize_review_priority(raw.get("review_priority")).value,
}
Expand All @@ -47,13 +51,15 @@ def pull_request_review_machine_configuration_namespace() -> MachineConfiguratio
apply_public_update=lambda _current, update: dict(update),
title="Pull-request review",
description=(
"Machine default for the PR review queue. Other developers are ranked "
"first by default; owner-first is an explicit opt-in. This changes "
"ordering only and grants no GitHub, Todo, push, or merge authority."
"Machine defaults for review priority and CI waiting, with complete "
"Goal overrides. Other developers rank first and CI waiting is enabled "
"by default. Disabling CI waiting preserves required local validation "
"and grants no GitHub, Todo, push, or merge authority."
),
default_configuration={
"schema_version": PULL_REQUEST_REVIEW_MACHINE_DEFAULTS_SCHEMA,
"review_priority": DEFAULT_REVIEW_PRIORITY.value,
"wait_for_ci": True,
},
)

Expand Down
30 changes: 17 additions & 13 deletions loopx/capabilities/pr_review_queue/merge_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def build_merge_readiness(
item: Mapping[str, Any],
review_threads: Mapping[str, Any],
source: str,
wait_for_ci: bool = True,
) -> dict[str, Any]:
"""Return a read-only pre-merge gate without granting merge authority."""

Expand Down Expand Up @@ -59,17 +60,18 @@ def build_merge_readiness(
if review_decision != "APPROVED" and not author_owned_fallback:
blockers.append("github_review_decision_not_approved")

total_checks = checks.get("total")
successful_checks = check_counts.get("success", 0)
if type(total_checks) is not int or total_checks <= 0:
blockers.append("status_checks_missing")
else:
if check_counts.get("failure", 0):
blockers.append("status_checks_failed")
if check_counts.get("pending", 0):
blockers.append("status_checks_pending")
if successful_checks != total_checks:
blockers.append("status_checks_incomplete")
if wait_for_ci:
total_checks = checks.get("total")
successful_checks = check_counts.get("success", 0)
if type(total_checks) is not int or total_checks <= 0:
blockers.append("status_checks_missing")
else:
if check_counts.get("failure", 0):
blockers.append("status_checks_failed")
if check_counts.get("pending", 0):
blockers.append("status_checks_pending")
if successful_checks != total_checks:
blockers.append("status_checks_incomplete")

if not thread_complete:
blockers.append("review_threads_incomplete")
Expand All @@ -83,7 +85,7 @@ def build_merge_readiness(
blockers.append("merge_state_requires_update")
elif merge_state in {"", "UNKNOWN"}:
blockers.append("merge_state_unverified")
elif merge_state == "BLOCKED" and not author_owned_fallback:
elif merge_state == "BLOCKED" and wait_for_ci and not author_owned_fallback:
blockers.append("repository_merge_state_blocked")

blockers = list(dict.fromkeys(blockers))
Expand All @@ -104,8 +106,10 @@ def build_merge_readiness(
"review_threads": dict(review_threads),
"author_owned_commented_approval": author_owned_fallback,
"admin_bypass_required": bool(
author_owned_fallback and merge_state == "BLOCKED"
(author_owned_fallback or not wait_for_ci) and merge_state == "BLOCKED"
),
"ci_policy": "required" if wait_for_ci else "not_consulted",
"wait_for_ci": wait_for_ci,
"blocking_reasons": blockers,
"authority": {
"grants_merge_authority": False,
Expand Down
Loading