Add harness coding docs for SoftBox (Go + Gio dual-build)
Harness governance / validate (push) Has been cancelled
Harness governance / validate (push) Has been cancelled
Initialize the full harness coding document set from the harness_coding_docs template, customized for the SoftBox project: - Vision, requirements, tech stack (modern Go 1.25 + Gio v0.10.1; Win7 legacy Go 1.20.14 + Gio v0.6.0), architecture, coding rules - Protocol contracts (signed catalog, package protocol v1, Ed25519 license, events, CLI) and Gio view structure - Roadmap Phase 0-6 with 20 suggested tasks; T-001 (monorepo skeleton) filed and ready to claim - Agent entry points (AGENTS.md, docs/00-ai-start-here.md), context manifest, governance scripts and tests - Merge Go gitignore with harness rules; keep go.work tracked Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,688 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only audit of Harness Coding task coordination in Gitea."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from setup_gitea_labels import ApiError, GiteaClient, LABELS, validate_config
|
||||
from validate_harness_governance import (
|
||||
TASK_ID,
|
||||
is_safe_repo_path,
|
||||
parse_frontmatter,
|
||||
parse_frontmatter_text,
|
||||
scopes_overlap,
|
||||
)
|
||||
|
||||
|
||||
STATUS_LABELS = {
|
||||
"status/todo",
|
||||
"status/doing",
|
||||
"status/blocked",
|
||||
"status/review",
|
||||
"status/done",
|
||||
}
|
||||
ACTIVE_LABELS = {"status/doing", "status/blocked", "status/review"}
|
||||
READY_OR_ACTIVE_LABELS = ACTIVE_LABELS | {"status/todo"}
|
||||
TASK_IN_TITLE = re.compile(r"^\[(T-\d{3}[a-z]?)\]")
|
||||
BODY_TASK_ID = re.compile(r"(?m)^\s*-\s*task_id:\s*`?(T-\d{3}[a-z]?)`?\s*$")
|
||||
BODY_TASK_FILE = re.compile(
|
||||
r"(?m)^\s*-\s*task_file:\s*`?(docs/tasks/T-\d{3}[a-z]?\.md)`?\s*$"
|
||||
)
|
||||
FIELD = re.compile(r"(?m)^\s*(?:-\s*)?([a-z_]+):\s*`?([^`\r\n]+?)`?\s*$")
|
||||
MAX_LEASE = timedelta(hours=24)
|
||||
CLOCK_SKEW = timedelta(minutes=5)
|
||||
CLAIM_IDENTITY_FIELDS = (
|
||||
"task",
|
||||
"claimed_by",
|
||||
"allocated_by",
|
||||
"context_ref",
|
||||
"claim_branch",
|
||||
"work_branch",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class AuditFinding:
|
||||
issue: int
|
||||
rule: str
|
||||
message: str
|
||||
|
||||
def render(self) -> str:
|
||||
subject = "repository" if self.issue <= 0 else f"issue #{self.issue}"
|
||||
return f"ERROR [{self.rule}] {subject}: {self.message}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoteTask:
|
||||
number: int
|
||||
task_id: str
|
||||
state: str
|
||||
status: str | None
|
||||
labels: set[str]
|
||||
body: str
|
||||
work_branch: str | None = None
|
||||
context_ref: str | None = None
|
||||
claimed_by: str | None = None
|
||||
claimed_at: datetime | None = None
|
||||
lease_until: datetime | None = None
|
||||
write_paths: list[str] | None = None
|
||||
|
||||
|
||||
def label_names(item: dict[str, Any]) -> set[str]:
|
||||
labels = item.get("labels")
|
||||
if not isinstance(labels, list):
|
||||
return set()
|
||||
return {
|
||||
label["name"]
|
||||
for label in labels
|
||||
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
||||
}
|
||||
|
||||
|
||||
def paged(client: GiteaClient, path: str) -> list[dict[str, Any]]:
|
||||
result: list[dict[str, Any]] = []
|
||||
seen_pages: set[tuple[str, ...]] = set()
|
||||
page = 1
|
||||
separator = "&" if "?" in path else "?"
|
||||
while True:
|
||||
values = client.request("GET", f"{path}{separator}limit=50&page={page}")
|
||||
if not isinstance(values, list):
|
||||
raise RuntimeError("Gitea 分页响应格式异常。")
|
||||
if not values:
|
||||
return result
|
||||
if not all(isinstance(value, dict) for value in values):
|
||||
raise RuntimeError("Gitea 分页响应包含非对象条目。")
|
||||
signature = tuple(
|
||||
str(value.get("id") or value.get("number") or value.get("name"))
|
||||
for value in values
|
||||
)
|
||||
if signature in seen_pages or page > 1000:
|
||||
raise RuntimeError("Gitea 分页重复,已停止以避免无限读取。")
|
||||
seen_pages.add(signature)
|
||||
result.extend(value for value in values if isinstance(value, dict))
|
||||
page += 1
|
||||
|
||||
|
||||
def parse_fields(text: str) -> dict[str, str]:
|
||||
return {match.group(1): match.group(2).strip() for match in FIELD.finditer(text)}
|
||||
|
||||
|
||||
def parse_write_paths(text: str) -> list[str]:
|
||||
lines = text.splitlines()
|
||||
values: list[str] = []
|
||||
collecting = False
|
||||
for line in lines:
|
||||
if re.match(r"^\s*(?:-\s*)?write_paths:\s*$", line):
|
||||
collecting = True
|
||||
continue
|
||||
if collecting:
|
||||
item = re.match(r"^\s+-\s+`?([^`\r\n]+?)`?\s*$", line)
|
||||
if item:
|
||||
value = item.group(1).strip()
|
||||
if "【" not in value:
|
||||
values.append(value)
|
||||
continue
|
||||
if line.strip():
|
||||
break
|
||||
return values
|
||||
|
||||
|
||||
def parse_datetime(value: str | None) -> datetime | None:
|
||||
if not value or "【" in value:
|
||||
return None
|
||||
if not re.fullmatch(
|
||||
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})",
|
||||
value,
|
||||
):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def select_latest_claim(
|
||||
comments: list[dict[str, Any]], task_id: str, dispatcher_login: str | None
|
||||
) -> tuple[str | None, list[str]]:
|
||||
required = {
|
||||
"task",
|
||||
"claimed_by",
|
||||
"allocated_by",
|
||||
"context_ref",
|
||||
"claim_branch",
|
||||
"work_branch",
|
||||
"claimed_at",
|
||||
"lease_until",
|
||||
}
|
||||
if not dispatcher_login:
|
||||
return None, ["未配置可信 dispatcher Gitea 登录名,无法验证 CLAIM 作者。"]
|
||||
|
||||
claims: list[tuple[int, str, dict[str, str], str, str]] = []
|
||||
for index, comment in enumerate(comments):
|
||||
body = comment.get("body")
|
||||
if not isinstance(body, str):
|
||||
continue
|
||||
lines = body.strip().splitlines()
|
||||
if not lines or lines[0].strip() not in {"CLAIM", "CLAIM RENEWAL"}:
|
||||
continue
|
||||
marker = lines[0].strip()
|
||||
fields = parse_fields(body)
|
||||
if fields.get("task") != task_id or not required.issubset(fields):
|
||||
continue
|
||||
if not parse_write_paths(body):
|
||||
continue
|
||||
user = comment.get("user")
|
||||
author = str(user.get("login") or "") if isinstance(user, dict) else ""
|
||||
comment_id = comment.get("id")
|
||||
order = comment_id if isinstance(comment_id, int) else index
|
||||
claims.append((order, marker, fields, author, body))
|
||||
|
||||
selected: str | None = None
|
||||
identity: tuple[str, ...] | None = None
|
||||
errors: list[str] = []
|
||||
for _, marker, fields, author, body in sorted(claims, key=lambda item: item[0]):
|
||||
if author != dispatcher_login or fields.get("allocated_by") != dispatcher_login:
|
||||
errors.append("CLAIM 必须由配置的 dispatcher 账号发布,且 allocated_by 与作者一致。")
|
||||
continue
|
||||
candidate_identity = tuple(fields.get(key, "") for key in CLAIM_IDENTITY_FIELDS)
|
||||
if marker == "CLAIM":
|
||||
if identity is not None:
|
||||
errors.append("同一任务存在重复的初始 CLAIM。")
|
||||
continue
|
||||
identity = candidate_identity
|
||||
selected = body
|
||||
continue
|
||||
if identity is None:
|
||||
errors.append("CLAIM RENEWAL 之前缺少有效初始 CLAIM。")
|
||||
continue
|
||||
if candidate_identity != identity:
|
||||
errors.append("CLAIM RENEWAL 改变了任务、领取者、dispatcher 或分支身份字段。")
|
||||
continue
|
||||
selected = body
|
||||
return selected, sorted(set(errors))
|
||||
|
||||
|
||||
def latest_claim(
|
||||
comments: list[dict[str, Any]], task_id: str, dispatcher_login: str | None
|
||||
) -> str | None:
|
||||
return select_latest_claim(comments, task_id, dispatcher_login)[0]
|
||||
|
||||
|
||||
def pull_request_head(pr: dict[str, Any]) -> str:
|
||||
head = pr.get("head")
|
||||
return str(head.get("ref") or "") if isinstance(head, dict) else ""
|
||||
|
||||
|
||||
def branch_commits(branches: list[dict[str, Any]]) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for branch in branches:
|
||||
name = branch.get("name")
|
||||
commit = branch.get("commit")
|
||||
if isinstance(name, str) and isinstance(commit, dict) and isinstance(
|
||||
commit.get("id"), str
|
||||
):
|
||||
result[name] = commit["id"]
|
||||
return result
|
||||
|
||||
|
||||
def validate_pull_request(
|
||||
pr: dict[str, Any], task: RemoteTask
|
||||
) -> list[AuditFinding]:
|
||||
findings: list[AuditFinding] = []
|
||||
if not task.work_branch or pull_request_head(pr) != task.work_branch:
|
||||
findings.append(
|
||||
AuditFinding(task.number, "pull-request", "PR head 与 claim 工作分支不一致。")
|
||||
)
|
||||
body = pr.get("body")
|
||||
body = body if isinstance(body, str) else ""
|
||||
required_values = [f"docs/tasks/{task.task_id}.md"]
|
||||
if task.context_ref:
|
||||
required_values.append(task.context_ref)
|
||||
required_values.extend(task.write_paths or [])
|
||||
if not re.search(rf"(?i)\bCloses\s+#{task.number}\b", body):
|
||||
findings.append(AuditFinding(task.number, "pull-request", "PR body 未链接对应 Issue。"))
|
||||
if any(value not in body for value in required_values):
|
||||
findings.append(
|
||||
AuditFinding(task.number, "pull-request", "PR body 缺少任务、context_ref 或写路径。")
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def remote_task_metadata(
|
||||
client: GiteaClient, task_id: str, branch: str
|
||||
) -> tuple[dict[str, Any] | None, list[str]]:
|
||||
file_path = urllib.parse.quote(f"docs/tasks/{task_id}.md", safe="/")
|
||||
ref = urllib.parse.quote(branch, safe="")
|
||||
try:
|
||||
response = client.request("GET", f"/contents/{file_path}?ref={ref}")
|
||||
except ApiError as exc:
|
||||
if exc.status == 404:
|
||||
return None, ["工作分支缺少任务文件。"]
|
||||
raise
|
||||
if not isinstance(response, dict) or not isinstance(response.get("content"), str):
|
||||
return None, ["工作分支任务文件响应格式异常。"]
|
||||
try:
|
||||
text = base64.b64decode(response["content"]).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None, ["工作分支任务文件不是有效 UTF-8 / base64。"]
|
||||
metadata, _, errors = parse_frontmatter_text(text)
|
||||
return metadata, errors
|
||||
|
||||
|
||||
def local_tasks(root: Path) -> dict[str, dict[str, Any]]:
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
task_dir = root / "docs" / "tasks"
|
||||
if not task_dir.is_dir():
|
||||
return result
|
||||
for path in sorted(task_dir.glob("T-*.md")):
|
||||
metadata, _, _ = parse_frontmatter(path)
|
||||
task_id = metadata.get("id")
|
||||
if isinstance(task_id, str) and TASK_ID.fullmatch(task_id):
|
||||
result[task_id] = metadata
|
||||
return result
|
||||
|
||||
|
||||
def audit_labels(client: GiteaClient) -> list[AuditFinding]:
|
||||
findings: list[AuditFinding] = []
|
||||
existing = client.list_labels()
|
||||
for desired in LABELS:
|
||||
current = existing.get(desired["name"])
|
||||
if current is None:
|
||||
findings.append(AuditFinding(0, "labels", f"缺少 {desired['name']}。"))
|
||||
elif bool(current.get("exclusive")) != desired["exclusive"]:
|
||||
findings.append(AuditFinding(0, "labels", f"{desired['name']} exclusive 属性不一致。"))
|
||||
return findings
|
||||
|
||||
|
||||
def audit_repository(
|
||||
root: Path,
|
||||
client: GiteaClient,
|
||||
now: datetime,
|
||||
dispatcher_login: str | None = None,
|
||||
) -> tuple[list[AuditFinding], int]:
|
||||
findings = audit_labels(client)
|
||||
issues = [
|
||||
issue
|
||||
for issue in paged(client, "/issues?state=all&type=issues")
|
||||
if "kind/task" in label_names(issue) and not issue.get("pull_request")
|
||||
]
|
||||
branches = branch_commits(paged(client, "/branches"))
|
||||
pull_requests = paged(client, "/pulls?state=all")
|
||||
local = local_tasks(root)
|
||||
remote: dict[str, RemoteTask] = {}
|
||||
|
||||
for issue in issues:
|
||||
number = issue.get("number")
|
||||
title = issue.get("title")
|
||||
body = issue.get("body")
|
||||
state = issue.get("state")
|
||||
if not isinstance(number, int) or not isinstance(title, str):
|
||||
continue
|
||||
body = body if isinstance(body, str) else ""
|
||||
title_match = TASK_IN_TITLE.search(title)
|
||||
body_match = BODY_TASK_ID.search(body)
|
||||
task_id = title_match.group(1) if title_match else ""
|
||||
if not task_id:
|
||||
findings.append(AuditFinding(number, "mapping", "标题缺少 [T-编号]。"))
|
||||
if body_match is None or body_match.group(1) != task_id:
|
||||
findings.append(AuditFinding(number, "mapping", "正文 task_id 与标题不一致。"))
|
||||
task_file_match = BODY_TASK_FILE.search(body)
|
||||
if task_id and (
|
||||
task_file_match is None
|
||||
or task_file_match.group(1) != f"docs/tasks/{task_id}.md"
|
||||
):
|
||||
findings.append(AuditFinding(number, "mapping", "task_file 与任务 ID 不一致。"))
|
||||
if task_id in remote:
|
||||
findings.append(AuditFinding(number, "mapping", "任务 ID 映射到多个 Issue。"))
|
||||
|
||||
labels = label_names(issue)
|
||||
statuses = sorted(labels & STATUS_LABELS)
|
||||
status = statuses[0] if len(statuses) == 1 else None
|
||||
local_issue = local.get(task_id, {}).get("issue") if task_id else None
|
||||
if len(statuses) > 1:
|
||||
findings.append(AuditFinding(number, "status", "存在多个 status/* 标签。"))
|
||||
elif not statuses and local_issue == number:
|
||||
findings.append(AuditFinding(number, "status", "已映射任务缺少 status/* 标签。"))
|
||||
if status:
|
||||
if task_id not in local or local_issue != number:
|
||||
findings.append(AuditFinding(number, "mapping", "可领取 Issue 未映射默认分支任务文件。"))
|
||||
else:
|
||||
local_status = local[task_id].get("status")
|
||||
expected_local = "DONE" if status == "status/done" else "TODO"
|
||||
if local_status != expected_local:
|
||||
findings.append(
|
||||
AuditFinding(
|
||||
number,
|
||||
"status",
|
||||
f"远端 {status} 要求默认分支任务为 {expected_local}。",
|
||||
)
|
||||
)
|
||||
if status in READY_OR_ACTIVE_LABELS:
|
||||
deps = local[task_id].get("deps")
|
||||
if not isinstance(deps, list):
|
||||
findings.append(
|
||||
AuditFinding(number, "dependency", "默认分支任务 deps 不是列表。")
|
||||
)
|
||||
else:
|
||||
unready = sorted(
|
||||
str(dep)
|
||||
for dep in deps
|
||||
if not isinstance(dep, str)
|
||||
or dep not in local
|
||||
or local[dep].get("status") != "DONE"
|
||||
)
|
||||
if unready:
|
||||
findings.append(
|
||||
AuditFinding(
|
||||
number,
|
||||
"dependency",
|
||||
"任务依赖尚未全部 DONE:" + ", ".join(unready) + "。",
|
||||
)
|
||||
)
|
||||
for prefix in ("type/", "priority/"):
|
||||
scoped = [name for name in labels if name.startswith(prefix)]
|
||||
if len(scoped) != 1:
|
||||
findings.append(
|
||||
AuditFinding(number, "labels", f"可领取任务必须恰有一个 {prefix} 标签。")
|
||||
)
|
||||
if status == "status/done" and state != "closed":
|
||||
findings.append(AuditFinding(number, "status", "status/done 的 Issue 必须关闭。"))
|
||||
if status != "status/done" and state == "closed":
|
||||
findings.append(AuditFinding(number, "status", "未完成 Issue 不应关闭。"))
|
||||
|
||||
task = RemoteTask(
|
||||
number=number,
|
||||
task_id=task_id,
|
||||
state=state if isinstance(state, str) else "",
|
||||
status=status,
|
||||
labels=labels,
|
||||
body=body,
|
||||
write_paths=parse_write_paths(body),
|
||||
)
|
||||
if task_id:
|
||||
remote[task_id] = task
|
||||
|
||||
local_metadata = local.get(task_id, {})
|
||||
if status == "status/done":
|
||||
work_branch = local_metadata.get("work_branch")
|
||||
context_ref = local_metadata.get("context_ref")
|
||||
write_paths = local_metadata.get("write_paths")
|
||||
task.work_branch = work_branch if isinstance(work_branch, str) else None
|
||||
task.context_ref = context_ref if isinstance(context_ref, str) else None
|
||||
task.write_paths = (
|
||||
[value for value in write_paths if isinstance(value, str)]
|
||||
if isinstance(write_paths, list)
|
||||
else []
|
||||
)
|
||||
if not task.work_branch or not task.context_ref or not task.write_paths:
|
||||
findings.append(
|
||||
AuditFinding(number, "task-file", "DONE 任务缺少长期分支、context_ref 或写路径。")
|
||||
)
|
||||
|
||||
claim_branch = f"claims/{task_id}" if task_id else ""
|
||||
if status in ACTIVE_LABELS:
|
||||
if claim_branch not in branches:
|
||||
findings.append(AuditFinding(number, "claim", "活跃任务缺少 claim 分支。"))
|
||||
comments = paged(client, f"/issues/{number}/comments")
|
||||
claim, claim_errors = select_latest_claim(
|
||||
comments, task_id, dispatcher_login
|
||||
)
|
||||
for message in claim_errors:
|
||||
findings.append(AuditFinding(number, "claim-author", message))
|
||||
if claim is None:
|
||||
findings.append(
|
||||
AuditFinding(number, "claim", "活跃任务缺少由可信 dispatcher 发布的结构化 CLAIM。")
|
||||
)
|
||||
else:
|
||||
fields = parse_fields(claim)
|
||||
task.work_branch = fields.get("work_branch")
|
||||
task.context_ref = fields.get("context_ref")
|
||||
task.claimed_by = fields.get("claimed_by")
|
||||
task.claimed_at = parse_datetime(fields.get("claimed_at"))
|
||||
task.lease_until = parse_datetime(fields.get("lease_until"))
|
||||
claim_paths = parse_write_paths(claim)
|
||||
if claim_paths:
|
||||
task.write_paths = claim_paths
|
||||
required_claim = {
|
||||
"task",
|
||||
"claimed_by",
|
||||
"allocated_by",
|
||||
"context_ref",
|
||||
"claim_branch",
|
||||
"work_branch",
|
||||
"claimed_at",
|
||||
"lease_until",
|
||||
}
|
||||
missing_claim = sorted(required_claim - set(fields))
|
||||
if missing_claim:
|
||||
findings.append(
|
||||
AuditFinding(
|
||||
number,
|
||||
"claim",
|
||||
"CLAIM 缺少字段:" + ", ".join(missing_claim) + "。",
|
||||
)
|
||||
)
|
||||
if fields.get("task") != task_id:
|
||||
findings.append(AuditFinding(number, "claim", "CLAIM task 不一致。"))
|
||||
if fields.get("claim_branch") != claim_branch:
|
||||
findings.append(AuditFinding(number, "claim", "CLAIM claim_branch 不一致。"))
|
||||
context_ref = task.context_ref
|
||||
if not context_ref or not re.fullmatch(r"[0-9a-fA-F]{40}", context_ref):
|
||||
findings.append(AuditFinding(number, "claim", "CLAIM context_ref 无效。"))
|
||||
elif branches.get(claim_branch) != context_ref:
|
||||
findings.append(AuditFinding(number, "claim", "claim 分支 SHA 与 context_ref 不一致。"))
|
||||
if (
|
||||
not task.claimed_by
|
||||
or not re.fullmatch(r"[A-Za-z0-9._-]+", task.claimed_by)
|
||||
or task.work_branch != f"agent/{task.claimed_by}/{task_id}"
|
||||
):
|
||||
findings.append(AuditFinding(number, "claim", "claimed_by 与工作分支命名不一致。"))
|
||||
if (
|
||||
not claim_paths
|
||||
or len(claim_paths) != len(set(claim_paths))
|
||||
or any(not is_safe_repo_path(path) for path in claim_paths)
|
||||
or f"docs/tasks/{task_id}.md" not in claim_paths
|
||||
):
|
||||
findings.append(
|
||||
AuditFinding(
|
||||
number,
|
||||
"claim",
|
||||
"CLAIM write_paths 必须安全、唯一并包含任务文件。",
|
||||
)
|
||||
)
|
||||
if not task.work_branch or task.work_branch not in branches:
|
||||
findings.append(AuditFinding(number, "claim", "工作分支不存在。"))
|
||||
elif task.work_branch:
|
||||
metadata, metadata_errors = remote_task_metadata(
|
||||
client, task_id, task.work_branch
|
||||
)
|
||||
for _ in metadata_errors:
|
||||
findings.append(AuditFinding(number, "task-file", "工作分支任务文件无效。"))
|
||||
if metadata is not None:
|
||||
expected_status = {
|
||||
"status/doing": "DOING",
|
||||
"status/blocked": "BLOCKED",
|
||||
"status/review": "DONE",
|
||||
}.get(status)
|
||||
comparisons = {
|
||||
"id": task_id,
|
||||
"issue": number,
|
||||
"context_ref": context_ref,
|
||||
"claim_branch": claim_branch,
|
||||
"work_branch": task.work_branch,
|
||||
"status": expected_status,
|
||||
}
|
||||
for key, expected in comparisons.items():
|
||||
if metadata.get(key) != expected:
|
||||
findings.append(
|
||||
AuditFinding(
|
||||
number,
|
||||
"task-file",
|
||||
f"工作分支任务字段 {key} 与协调状态不一致。",
|
||||
)
|
||||
)
|
||||
metadata_paths = metadata.get("write_paths")
|
||||
if not isinstance(metadata_paths, list) or set(metadata_paths) != set(
|
||||
claim_paths
|
||||
):
|
||||
findings.append(
|
||||
AuditFinding(
|
||||
number,
|
||||
"task-file",
|
||||
"工作分支 write_paths 与 CLAIM 不一致。",
|
||||
)
|
||||
)
|
||||
if task.claimed_at is None:
|
||||
findings.append(AuditFinding(number, "stale", "CLAIM 缺少有效 claimed_at。"))
|
||||
elif task.claimed_at > now + CLOCK_SKEW:
|
||||
findings.append(AuditFinding(number, "stale", "claimed_at 超出允许时钟偏差。"))
|
||||
if task.lease_until is None:
|
||||
findings.append(AuditFinding(number, "stale", "CLAIM 缺少有效 lease_until。"))
|
||||
elif task.claimed_at is not None:
|
||||
if task.lease_until <= task.claimed_at:
|
||||
findings.append(AuditFinding(number, "stale", "lease_until 必须晚于 claimed_at。"))
|
||||
elif task.lease_until - task.claimed_at > MAX_LEASE:
|
||||
findings.append(AuditFinding(number, "stale", "claim 租期不得超过 24 小时。"))
|
||||
if task.lease_until <= now:
|
||||
findings.append(AuditFinding(number, "stale", "claim 已过期,需人工审查回收。"))
|
||||
elif status == "status/todo" and claim_branch in branches:
|
||||
findings.append(AuditFinding(number, "claim", "TODO 仍存在 claim 分支。"))
|
||||
|
||||
matching_prs = [
|
||||
pr
|
||||
for pr in pull_requests
|
||||
if task_id and TASK_IN_TITLE.search(str(pr.get("title") or ""))
|
||||
and TASK_IN_TITLE.search(str(pr.get("title") or "")).group(1) == task_id
|
||||
]
|
||||
if status == "status/review":
|
||||
open_prs = [pr for pr in matching_prs if pr.get("state") == "open"]
|
||||
if len(open_prs) != 1:
|
||||
findings.append(
|
||||
AuditFinding(number, "pull-request", "status/review 必须恰有一个 open PR。")
|
||||
)
|
||||
else:
|
||||
findings.extend(validate_pull_request(open_prs[0], task))
|
||||
if status == "status/done":
|
||||
merged_prs = [pr for pr in matching_prs if bool(pr.get("merged"))]
|
||||
if len(merged_prs) != 1:
|
||||
findings.append(
|
||||
AuditFinding(number, "pull-request", "status/done 必须恰有一个 merged PR。")
|
||||
)
|
||||
else:
|
||||
findings.extend(validate_pull_request(merged_prs[0], task))
|
||||
|
||||
for task_id, metadata in sorted(local.items()):
|
||||
issue_number = metadata.get("issue")
|
||||
if isinstance(issue_number, int):
|
||||
mapped = remote.get(task_id)
|
||||
if mapped is None or mapped.number != issue_number:
|
||||
findings.append(AuditFinding(issue_number, "mapping", f"本地 {task_id} 没有唯一远端映射。"))
|
||||
elif metadata.get("status") == "DONE" and mapped.status != "status/done":
|
||||
findings.append(AuditFinding(issue_number, "status", "本地 DONE 与远端状态不一致。"))
|
||||
|
||||
active = sorted(
|
||||
(task for task in remote.values() if task.status in ACTIVE_LABELS),
|
||||
key=lambda task: task.task_id,
|
||||
)
|
||||
workers: dict[str, str] = {}
|
||||
for task in active:
|
||||
if not task.claimed_by:
|
||||
continue
|
||||
previous = workers.get(task.claimed_by)
|
||||
if previous:
|
||||
findings.append(
|
||||
AuditFinding(
|
||||
task.number,
|
||||
"worker-overlap",
|
||||
f"claimed_by 同时活跃于 {previous} 和 {task.task_id}。",
|
||||
)
|
||||
)
|
||||
else:
|
||||
workers[task.claimed_by] = task.task_id
|
||||
for index, left in enumerate(active):
|
||||
for right in active[index + 1 :]:
|
||||
if any(
|
||||
scopes_overlap(a, b)
|
||||
for a in left.write_paths or []
|
||||
for b in right.write_paths or []
|
||||
):
|
||||
findings.append(
|
||||
AuditFinding(
|
||||
right.number,
|
||||
"scope-overlap",
|
||||
f"活跃 write_paths 与 {left.task_id} 重叠。",
|
||||
)
|
||||
)
|
||||
return sorted(set(findings)), len(issues)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="只读审计 Gitea 任务协调状态。")
|
||||
parser.add_argument("--repo", help="目标 owner/repo;也可设置 GITEA_REPOSITORY。")
|
||||
parser.add_argument(
|
||||
"--dispatcher",
|
||||
default=os.environ.get("GITEA_DISPATCHER_LOGIN"),
|
||||
help="可信 dispatcher 的 Gitea 登录名;也可设置 GITEA_DISPATCHER_LOGIN。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parents[1],
|
||||
help="本地仓库根目录。",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
root = args.root.resolve()
|
||||
if not root.is_dir() or not (root / "docs" / "tasks").is_dir():
|
||||
print("ERROR: --root 必须是包含 docs/tasks 的仓库目录。", file=sys.stderr)
|
||||
return 2
|
||||
repo = args.repo or os.environ.get("GITEA_REPOSITORY")
|
||||
if not repo:
|
||||
print("ERROR: 需要 --repo owner/repo 或 GITEA_REPOSITORY。", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
root_url, owner, name = validate_config(
|
||||
os.environ.get("GITEA_URL", ""),
|
||||
os.environ.get("GITEA_TOKEN", ""),
|
||||
repo,
|
||||
)
|
||||
client = GiteaClient(root_url, owner, name, os.environ["GITEA_TOKEN"])
|
||||
findings, count = audit_repository(
|
||||
root, client, datetime.now(timezone.utc), args.dispatcher
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
except ApiError as exc:
|
||||
print(f"ERROR: Gitea 只读审计失败(HTTP {exc.status})。", file=sys.stderr)
|
||||
return 2
|
||||
except RuntimeError:
|
||||
print("ERROR: Gitea 只读审计失败(网络、代理或响应格式异常)。", file=sys.stderr)
|
||||
return 2
|
||||
if findings:
|
||||
for finding in findings:
|
||||
print(finding.render(), file=sys.stderr)
|
||||
print(f"Gitea 协调审计失败:{len(findings)} 项不一致。", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Gitea 协调审计通过:检查 {count} 个 kind/task Issue,未执行远端写入。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$EnvFile = $(
|
||||
if ($env:GITEA_ENV_FILE) {
|
||||
$env:GITEA_ENV_FILE
|
||||
} else {
|
||||
Join-Path $HOME ".codex/gitea.env"
|
||||
}
|
||||
),
|
||||
[string]$Version = "0.5.1",
|
||||
[switch]$CheckConfig,
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$ServerArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$utf8 = [System.Text.UTF8Encoding]::new($false)
|
||||
[Console]::OutputEncoding = $utf8
|
||||
$OutputEncoding = $utf8
|
||||
|
||||
if (-not (Test-Path -LiteralPath $EnvFile -PathType Leaf)) {
|
||||
throw "Gitea MCP 配置文件不存在:$EnvFile"
|
||||
}
|
||||
|
||||
$values = @{}
|
||||
foreach ($rawLine in Get-Content -LiteralPath $EnvFile) {
|
||||
$line = $rawLine.Trim()
|
||||
if (-not $line -or $line.StartsWith("#")) {
|
||||
continue
|
||||
}
|
||||
|
||||
$pair = $line -split "=", 2
|
||||
if ($pair.Count -ne 2) {
|
||||
throw "Gitea MCP 配置行必须使用 KEY=VALUE 格式。"
|
||||
}
|
||||
|
||||
$values[$pair[0].Trim()] = $pair[1].Trim()
|
||||
}
|
||||
|
||||
foreach ($name in @("GITEA_URL", "GITEA_TOKEN")) {
|
||||
if (-not $values.ContainsKey($name) -or [string]::IsNullOrWhiteSpace($values[$name])) {
|
||||
throw "$name 未配置或为空。"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$giteaUri = [Uri]$values["GITEA_URL"]
|
||||
} catch {
|
||||
throw "GITEA_URL 不是有效 URL。"
|
||||
}
|
||||
|
||||
if ($giteaUri.Scheme -notin @("http", "https")) {
|
||||
throw "GITEA_URL 只支持 http 或 https。"
|
||||
}
|
||||
|
||||
if ($giteaUri.AbsolutePath.Trim("/") -ne "") {
|
||||
throw "GITEA_URL 必须填写实例根地址,不要包含 /api/v1;gitea-mcp 会自动追加 API 路径。"
|
||||
}
|
||||
|
||||
if ($giteaUri.Scheme -eq "http" -and $values["GITEA_ALLOW_INSECURE_HTTP"] -ne "1") {
|
||||
throw "当前使用 HTTP。确认接受 Token 明文传输风险后,在私有配置中设置 GITEA_ALLOW_INSECURE_HTTP=1。"
|
||||
}
|
||||
|
||||
foreach ($entry in $values.GetEnumerator()) {
|
||||
if ($entry.Key -like "GITEA_*") {
|
||||
Set-Item -Path "Env:$($entry.Key)" -Value $entry.Value
|
||||
}
|
||||
}
|
||||
|
||||
$noProxyEntries = @($env:NO_PROXY -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
if ($noProxyEntries -notcontains $giteaUri.Host) {
|
||||
$noProxyEntries += $giteaUri.Host
|
||||
}
|
||||
$env:NO_PROXY = $noProxyEntries -join ","
|
||||
$env:no_proxy = $env:NO_PROXY
|
||||
|
||||
if ($values["GITEA_DIRECT"] -eq "1") {
|
||||
foreach ($proxyVariable in @("ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy")) {
|
||||
Remove-Item -Path "Env:$proxyVariable" -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
if ($CheckConfig) {
|
||||
Write-Output "Gitea MCP 配置有效:URL=$($giteaUri.GetLeftPart([UriPartial]::Authority)),Token 已设置,版本=$Version。"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$uvx = Get-Command uvx -ErrorAction Stop
|
||||
$stderrLog = Join-Path ([IO.Path]::GetTempPath()) "gitea-mcp-$PID.stderr.log"
|
||||
& $uvx.Source --from "gitea-mcp==$Version" gitea-mcp @ServerArgs 2>> $stderrLog
|
||||
exit $LASTEXITCODE
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Preview or idempotently apply Harness Coding labels to one Gitea repo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
LABELS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"name": "kind/task",
|
||||
"color": "0052CC",
|
||||
"description": "Harness Coding task",
|
||||
"exclusive": False,
|
||||
},
|
||||
{
|
||||
"name": "type/docs",
|
||||
"color": "5319E7",
|
||||
"description": "Documentation change",
|
||||
"exclusive": True,
|
||||
},
|
||||
{
|
||||
"name": "type/code",
|
||||
"color": "1D76DB",
|
||||
"description": "Code or automation change",
|
||||
"exclusive": True,
|
||||
},
|
||||
{
|
||||
"name": "status/todo",
|
||||
"color": "C5DEF5",
|
||||
"description": "Ready to claim",
|
||||
"exclusive": True,
|
||||
},
|
||||
{
|
||||
"name": "status/doing",
|
||||
"color": "FBCA04",
|
||||
"description": "Claimed and in progress",
|
||||
"exclusive": True,
|
||||
},
|
||||
{
|
||||
"name": "status/blocked",
|
||||
"color": "D93F0B",
|
||||
"description": "Blocked; claim retained",
|
||||
"exclusive": True,
|
||||
},
|
||||
{
|
||||
"name": "status/review",
|
||||
"color": "BFD4F2",
|
||||
"description": "Pull request under review",
|
||||
"exclusive": True,
|
||||
},
|
||||
{
|
||||
"name": "status/done",
|
||||
"color": "0E8A16",
|
||||
"description": "Merged and completed",
|
||||
"exclusive": True,
|
||||
},
|
||||
{
|
||||
"name": "priority/p0",
|
||||
"color": "B60205",
|
||||
"description": "Highest priority",
|
||||
"exclusive": True,
|
||||
},
|
||||
{
|
||||
"name": "priority/p1",
|
||||
"color": "D93F0B",
|
||||
"description": "High priority",
|
||||
"exclusive": True,
|
||||
},
|
||||
{
|
||||
"name": "priority/p2",
|
||||
"color": "FBCA04",
|
||||
"description": "Normal priority",
|
||||
"exclusive": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
def __init__(self, status: int, reason: str) -> None:
|
||||
super().__init__(f"Gitea API 返回 HTTP {status}:{reason}")
|
||||
self.status = status
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Never forward the Authorization header to a redirected origin."""
|
||||
|
||||
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="读取远端差异,并可幂等创建或校正 Harness Coding Gitea 标签。"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo",
|
||||
default=os.environ.get("GITEA_REPOSITORY"),
|
||||
help="目标 owner/repo;也可设置 GITEA_REPOSITORY。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="应用预览中的 create/update;省略时只读远端并打印差异。",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def validate_config(url: str, token: str, repo: str) -> tuple[str, str, str]:
|
||||
parsed = urllib.parse.urlsplit(url.strip())
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise ValueError("GITEA_URL 必须是 http(s) 实例根地址。")
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ValueError("GITEA_URL 不得包含凭据、query 或 fragment。")
|
||||
path = parsed.path.rstrip("/")
|
||||
if path.lower().endswith("/api/v1"):
|
||||
raise ValueError("GITEA_URL 不得包含 /api/v1。")
|
||||
if parsed.scheme == "http" and os.environ.get("GITEA_ALLOW_INSECURE_HTTP") != "1":
|
||||
raise ValueError("HTTP 需要显式设置 GITEA_ALLOW_INSECURE_HTTP=1。")
|
||||
if not token:
|
||||
raise ValueError("缺少 GITEA_TOKEN。")
|
||||
parts = repo.split("/")
|
||||
if len(parts) != 2 or not all(parts):
|
||||
raise ValueError("--repo 必须使用 owner/repo 格式。")
|
||||
root = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
||||
return root.rstrip("/"), parts[0], parts[1]
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
def __init__(self, root: str, owner: str, repo: str, token: str) -> None:
|
||||
owner_q = urllib.parse.quote(owner, safe="")
|
||||
repo_q = urllib.parse.quote(repo, safe="")
|
||||
self.base = f"{root}/api/v1/repos/{owner_q}/{repo_q}"
|
||||
self.token = token
|
||||
proxy_handler = (
|
||||
urllib.request.ProxyHandler({})
|
||||
if os.environ.get("GITEA_DIRECT") == "1"
|
||||
else urllib.request.ProxyHandler()
|
||||
)
|
||||
self.opener = urllib.request.build_opener(proxy_handler, NoRedirect())
|
||||
|
||||
def request(
|
||||
self, method: str, path: str, payload: dict[str, Any] | None = None
|
||||
) -> Any:
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
self.base + path,
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"token {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with self.opener.open(request, timeout=30) as response:
|
||||
body = response.read()
|
||||
return json.loads(body.decode("utf-8")) if body else None
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise ApiError(exc.code, exc.reason) from None
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(f"连接 Gitea 失败:{exc.reason}") from None
|
||||
|
||||
def list_labels(self) -> dict[str, dict[str, Any]]:
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
seen_pages: set[tuple[str, ...]] = set()
|
||||
page = 1
|
||||
while True:
|
||||
labels = self.request("GET", f"/labels?limit=50&page={page}")
|
||||
if not isinstance(labels, list):
|
||||
raise RuntimeError("Gitea labels 响应格式异常。")
|
||||
if not labels:
|
||||
return result
|
||||
if not all(isinstance(label, dict) for label in labels):
|
||||
raise RuntimeError("Gitea labels 响应包含非对象条目。")
|
||||
signature = tuple(str(label.get("id") or label.get("name")) for label in labels)
|
||||
if signature in seen_pages or page > 1000:
|
||||
raise RuntimeError("Gitea labels 分页重复,已停止以避免无限读取。")
|
||||
seen_pages.add(signature)
|
||||
for label in labels:
|
||||
if isinstance(label, dict) and isinstance(label.get("name"), str):
|
||||
result[label["name"]] = label
|
||||
page += 1
|
||||
|
||||
|
||||
def normalize_color(value: Any) -> str:
|
||||
return str(value or "").lstrip("#").upper()
|
||||
|
||||
|
||||
def needs_update(current: dict[str, Any], desired: dict[str, Any]) -> bool:
|
||||
return (
|
||||
normalize_color(current.get("color")) != desired["color"]
|
||||
or str(current.get("description") or "") != desired["description"]
|
||||
or bool(current.get("exclusive")) != desired["exclusive"]
|
||||
)
|
||||
|
||||
|
||||
def build_plan(
|
||||
existing: dict[str, dict[str, Any]],
|
||||
) -> list[tuple[str, dict[str, Any], dict[str, Any] | None]]:
|
||||
plan = []
|
||||
for desired in LABELS:
|
||||
current = existing.get(desired["name"])
|
||||
if current is None:
|
||||
action = "create"
|
||||
elif needs_update(current, desired):
|
||||
action = "update"
|
||||
else:
|
||||
action = "unchanged"
|
||||
plan.append((action, desired, current))
|
||||
return plan
|
||||
|
||||
|
||||
def show_plan(repo: str, plan: list[tuple[str, dict[str, Any], Any]]) -> None:
|
||||
print(f"目标仓库:{repo}")
|
||||
for action, desired, _ in plan:
|
||||
scope = "exclusive" if desired["exclusive"] else "normal"
|
||||
print(f"- {action:9} {desired['name']} #{desired['color']} {scope}")
|
||||
counts = {name: sum(action == name for action, _, _ in plan) for name in (
|
||||
"create",
|
||||
"update",
|
||||
"unchanged",
|
||||
)}
|
||||
print(
|
||||
"计划汇总:"
|
||||
f"创建 {counts['create']},更新 {counts['update']},未变化 {counts['unchanged']}。"
|
||||
)
|
||||
|
||||
|
||||
def apply_plan(
|
||||
client: GiteaClient,
|
||||
plan: list[tuple[str, dict[str, Any], dict[str, Any] | None]],
|
||||
) -> None:
|
||||
created = updated = unchanged = 0
|
||||
for action, desired, current in plan:
|
||||
if action == "unchanged":
|
||||
unchanged += 1
|
||||
continue
|
||||
if action == "update":
|
||||
label_id = None if current is None else current.get("id")
|
||||
if not isinstance(label_id, int):
|
||||
raise RuntimeError(f"标签 {desired['name']} 缺少数字 id。")
|
||||
client.request("PATCH", f"/labels/{label_id}", dict(desired))
|
||||
updated += 1
|
||||
continue
|
||||
try:
|
||||
client.request("POST", "/labels", dict(desired))
|
||||
created += 1
|
||||
except ApiError as exc:
|
||||
if exc.status != 422:
|
||||
raise
|
||||
latest = client.list_labels().get(desired["name"])
|
||||
if latest is None:
|
||||
raise
|
||||
if needs_update(latest, desired):
|
||||
label_id = latest.get("id")
|
||||
if not isinstance(label_id, int):
|
||||
raise RuntimeError(f"标签 {desired['name']} 缺少数字 id。")
|
||||
client.request("PATCH", f"/labels/{label_id}", dict(desired))
|
||||
updated += 1
|
||||
else:
|
||||
unchanged += 1
|
||||
print(f"标签同步完成:创建 {created},更新 {updated},未变化 {unchanged}。")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if not args.repo:
|
||||
print("ERROR: 需要 --repo owner/repo 或 GITEA_REPOSITORY。", file=sys.stderr)
|
||||
return 2
|
||||
url = os.environ.get("GITEA_URL", "")
|
||||
token = os.environ.get("GITEA_TOKEN", "")
|
||||
try:
|
||||
root, owner, name = validate_config(url, token, args.repo)
|
||||
client = GiteaClient(root, owner, name, token)
|
||||
plan = build_plan(client.list_labels())
|
||||
show_plan(args.repo, plan)
|
||||
if not args.apply:
|
||||
print("dry-run:未写入;追加 --apply 才会应用上述 create/update。")
|
||||
return 0
|
||||
apply_plan(client, plan)
|
||||
return 0
|
||||
except (ValueError, ApiError, RuntimeError) as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compatibility smoke for Gitea's same-name claim branch race behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from setup_gitea_labels import ApiError, GiteaClient, validate_config
|
||||
|
||||
|
||||
PROBE_PREFIX = "claims/__probe__/race-"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="并发创建唯一临时 claim 分支,smoke 期望一个 201、一个 409。"
|
||||
)
|
||||
parser.add_argument("--repo", help="目标 owner/repo;也可设置 GITEA_REPOSITORY。")
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="执行两次写入并清理临时分支;省略时只读并打印计划。",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def new_probe_branch() -> str:
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
return f"{PROBE_PREFIX}{stamp}-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def branch_commit(client: GiteaClient, branch: str) -> str | None:
|
||||
encoded = urllib.parse.quote(branch, safe="")
|
||||
try:
|
||||
response = client.request("GET", f"/branches/{encoded}")
|
||||
except ApiError as exc:
|
||||
if exc.status == 404:
|
||||
return None
|
||||
raise
|
||||
if not isinstance(response, dict):
|
||||
raise RuntimeError("Gitea branch 响应格式异常。")
|
||||
commit = response.get("commit")
|
||||
if not isinstance(commit, dict) or not isinstance(commit.get("id"), str):
|
||||
raise RuntimeError("Gitea branch 响应缺少 commit.id。")
|
||||
return commit["id"]
|
||||
|
||||
|
||||
def repository_base(client: GiteaClient) -> tuple[str, str]:
|
||||
repository = client.request("GET", "")
|
||||
if not isinstance(repository, dict) or not isinstance(
|
||||
repository.get("default_branch"), str
|
||||
):
|
||||
raise RuntimeError("Gitea repository 响应缺少 default_branch。")
|
||||
default_branch = repository["default_branch"]
|
||||
commit = branch_commit(client, default_branch)
|
||||
if commit is None:
|
||||
raise RuntimeError("默认分支不存在。")
|
||||
return default_branch, commit
|
||||
|
||||
|
||||
def create_once(client: GiteaClient, barrier: threading.Barrier, branch: str, ref: str) -> int:
|
||||
barrier.wait(timeout=10)
|
||||
try:
|
||||
client.request(
|
||||
"POST",
|
||||
"/branches",
|
||||
{"new_branch_name": branch, "old_ref_name": ref},
|
||||
)
|
||||
return 201
|
||||
except ApiError as exc:
|
||||
return exc.status
|
||||
|
||||
|
||||
def cleanup_probe(client: GiteaClient, branch: str, expected_sha: str) -> None:
|
||||
if not branch.startswith(PROBE_PREFIX):
|
||||
raise RuntimeError("拒绝清理非探针分支。")
|
||||
actual_sha = branch_commit(client, branch)
|
||||
if actual_sha is None:
|
||||
return
|
||||
if actual_sha != expected_sha:
|
||||
raise RuntimeError("探针分支 SHA 与预期不一致,已保留供人工检查。")
|
||||
encoded = urllib.parse.quote(branch, safe="")
|
||||
client.request("DELETE", f"/branches/{encoded}")
|
||||
if branch_commit(client, branch) is not None:
|
||||
raise RuntimeError("探针分支清理后仍然存在。")
|
||||
|
||||
|
||||
def client_for(root: str, owner: str, repo: str, token: str) -> GiteaClient:
|
||||
return GiteaClient(root, owner, repo, token)
|
||||
|
||||
|
||||
def run_probe(root: str, owner: str, repo: str, token: str, branch: str, sha: str) -> list[int]:
|
||||
barrier = threading.Barrier(2)
|
||||
clients = [client_for(root, owner, repo, token) for _ in range(2)]
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [
|
||||
executor.submit(create_once, client, barrier, branch, sha) for client in clients
|
||||
]
|
||||
return sorted(future.result(timeout=40) for future in futures)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
repo_value = args.repo or os.environ.get("GITEA_REPOSITORY")
|
||||
if not repo_value:
|
||||
print("ERROR: 需要 --repo owner/repo 或 GITEA_REPOSITORY。", file=sys.stderr)
|
||||
return 2
|
||||
token = os.environ.get("GITEA_TOKEN", "")
|
||||
try:
|
||||
root, owner, repo = validate_config(
|
||||
os.environ.get("GITEA_URL", ""), token, repo_value
|
||||
)
|
||||
control = client_for(root, owner, repo, token)
|
||||
default_branch, sha = repository_base(control)
|
||||
branch = new_probe_branch()
|
||||
print(f"目标仓库:{repo_value}")
|
||||
print(f"基准分支:{default_branch} @ {sha}")
|
||||
print(f"临时分支:{branch}")
|
||||
if not args.apply:
|
||||
print("dry-run:未写入;追加 --apply 才会执行竞态探针和受控清理。")
|
||||
return 0
|
||||
|
||||
results: list[int] = []
|
||||
probe_error: Exception | None = None
|
||||
try:
|
||||
results = run_probe(root, owner, repo, token, branch, sha)
|
||||
except Exception as exc: # cleanup still has to run after partial writes
|
||||
probe_error = exc
|
||||
try:
|
||||
cleanup_probe(control, branch, sha)
|
||||
except (ApiError, RuntimeError) as cleanup_error:
|
||||
print(f"ERROR: 清理失败:{cleanup_error}", file=sys.stderr)
|
||||
return 2
|
||||
if probe_error is not None:
|
||||
print("ERROR: 竞态请求未完整返回;临时分支已安全清理。", file=sys.stderr)
|
||||
return 2
|
||||
print("竞态结果:" + ", ".join(str(status) for status in results))
|
||||
if results != [201, 409]:
|
||||
print(
|
||||
"ERROR: 未得到恰好一个 201 和一个 409;目标实例不符合预期 smoke,临时分支已安全清理。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print("claim 并发兼容性 smoke 通过;这不证明线性化,临时分支已删除并确认 404。")
|
||||
return 0
|
||||
except ValueError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
except ApiError as exc:
|
||||
print(f"ERROR: Gitea 探针失败(HTTP {exc.status})。", file=sys.stderr)
|
||||
return 2
|
||||
except RuntimeError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the agent context manifest with the Python standard library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
|
||||
EXPECTED_SCHEMA = "docs/agent-context.schema.json"
|
||||
REQUIRED_TOP_LEVEL = {
|
||||
"schema",
|
||||
"schema_version",
|
||||
"authority",
|
||||
"bootstrap",
|
||||
"routes",
|
||||
"tasks",
|
||||
"refresh",
|
||||
"degraded_mode",
|
||||
}
|
||||
REQUIRED_BOOTSTRAP = {
|
||||
"AGENTS.md",
|
||||
"docs/00-ai-start-here.md",
|
||||
"docs/05-coding-rules.md",
|
||||
"docs/current-state.md",
|
||||
}
|
||||
TASK_PATH_KEYS = {"roadmap", "directory", "template"}
|
||||
SENSITIVE_KEY = re.compile(r"(?:token|password|secret|credential)", re.IGNORECASE)
|
||||
URI_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
||||
|
||||
|
||||
def load_json(path: Path, root: Path, errors: list[str]) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
errors.append(f"文件不存在:{display_path(path, root)}")
|
||||
except json.JSONDecodeError as exc:
|
||||
errors.append(
|
||||
f"JSON 语法错误:{display_path(path, root)}:{exc.lineno}:{exc.colno}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def display_path(path: Path, root: Path) -> str:
|
||||
try:
|
||||
return path.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def require_mapping(value: Any, name: str, errors: list[str]) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
errors.append(f"{name} 必须是对象。")
|
||||
return {}
|
||||
return value
|
||||
|
||||
|
||||
def require_string_list(value: Any, name: str, errors: list[str]) -> list[str]:
|
||||
if not isinstance(value, list) or not value or not all(
|
||||
isinstance(item, str) and item for item in value
|
||||
):
|
||||
errors.append(f"{name} 必须是非空字符串数组。")
|
||||
return []
|
||||
if len(value) != len(set(value)):
|
||||
errors.append(f"{name} 不得包含重复路径。")
|
||||
return value
|
||||
|
||||
|
||||
def validate_repo_path(root: Path, value: str, name: str, errors: list[str]) -> None:
|
||||
path = PurePosixPath(value)
|
||||
if (
|
||||
path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or "\\" in value
|
||||
or URI_SCHEME.match(value)
|
||||
):
|
||||
errors.append(f"{name} 必须是安全的仓库相对路径:{value}")
|
||||
return
|
||||
|
||||
target = root.joinpath(*path.parts)
|
||||
if not target.exists():
|
||||
errors.append(f"{name} 引用路径不存在:{value}")
|
||||
|
||||
|
||||
def find_sensitive_keys(value: Any, location: str, errors: list[str]) -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
child_location = f"{location}.{key}"
|
||||
if SENSITIVE_KEY.search(key):
|
||||
errors.append(f"清单不得保存敏感配置字段:{child_location}")
|
||||
find_sensitive_keys(child, child_location, errors)
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
find_sensitive_keys(child, f"{location}[{index}]", errors)
|
||||
|
||||
|
||||
def validate_manifest(root: Path) -> list[str]:
|
||||
root = root.resolve()
|
||||
manifest_path = root / "docs" / "agent-context.json"
|
||||
errors: list[str] = []
|
||||
manifest = load_json(manifest_path, root, errors)
|
||||
schema = load_json(root / EXPECTED_SCHEMA, root, errors)
|
||||
if manifest is None or schema is None:
|
||||
return errors
|
||||
if not isinstance(schema, dict) or schema.get("type") != "object":
|
||||
errors.append("agent-context.schema.json 不是有效的对象 Schema。")
|
||||
|
||||
root_object = require_mapping(manifest, "manifest", errors)
|
||||
actual_keys = set(root_object)
|
||||
missing = sorted(REQUIRED_TOP_LEVEL - actual_keys)
|
||||
unexpected = sorted(actual_keys - REQUIRED_TOP_LEVEL)
|
||||
if missing:
|
||||
errors.append("缺少顶层字段:" + ", ".join(missing))
|
||||
if unexpected:
|
||||
errors.append("存在未知顶层字段:" + ", ".join(unexpected))
|
||||
if root_object.get("schema") != EXPECTED_SCHEMA:
|
||||
errors.append(f"schema 必须是 {EXPECTED_SCHEMA}。")
|
||||
if root_object.get("schema_version") != 1:
|
||||
errors.append("schema_version 必须为 1。")
|
||||
|
||||
authority = require_mapping(root_object.get("authority"), "authority", errors)
|
||||
for key in ("bootstrap", "framework_templates", "project_facts", "coordination"):
|
||||
if not isinstance(authority.get(key), str) or not authority[key]:
|
||||
errors.append(f"authority.{key} 必须是非空字符串。")
|
||||
|
||||
bootstrap = require_mapping(root_object.get("bootstrap"), "bootstrap", errors)
|
||||
always_read = require_string_list(
|
||||
bootstrap.get("always_read"), "bootstrap.always_read", errors
|
||||
)
|
||||
missing_bootstrap = sorted(REQUIRED_BOOTSTRAP - set(always_read))
|
||||
if missing_bootstrap:
|
||||
errors.append("bootstrap.always_read 缺少:" + ", ".join(missing_bootstrap))
|
||||
|
||||
routes = require_mapping(root_object.get("routes"), "routes", errors)
|
||||
if not routes:
|
||||
errors.append("routes 至少需要一个任务类型。")
|
||||
|
||||
path_values: list[tuple[str, str]] = [(EXPECTED_SCHEMA, "schema")]
|
||||
path_values.extend((path, "bootstrap.always_read") for path in always_read)
|
||||
for route, value in routes.items():
|
||||
paths = require_string_list(value, f"routes.{route}", errors)
|
||||
path_values.extend((path, f"routes.{route}") for path in paths)
|
||||
|
||||
tasks = require_mapping(root_object.get("tasks"), "tasks", errors)
|
||||
if set(tasks) != TASK_PATH_KEYS:
|
||||
errors.append("tasks 必须且只能包含 roadmap、directory、template。")
|
||||
for key in sorted(TASK_PATH_KEYS):
|
||||
value = tasks.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
path_values.append((value, f"tasks.{key}"))
|
||||
else:
|
||||
errors.append(f"tasks.{key} 必须是非空字符串。")
|
||||
|
||||
refresh = require_mapping(root_object.get("refresh"), "refresh", errors)
|
||||
expected_refresh = {
|
||||
"context_ref": "default_branch_head_sha",
|
||||
"cache_key": "file_sha",
|
||||
"unchanged_file": "reuse_within_current_session",
|
||||
"changed_ref": "reread_manifest_and_routed_documents",
|
||||
}
|
||||
if refresh != expected_refresh:
|
||||
errors.append("refresh 必须使用约定的提交 SHA 与文件 SHA 刷新策略。")
|
||||
|
||||
degraded = require_mapping(root_object.get("degraded_mode"), "degraded_mode", errors)
|
||||
expected_degraded = {
|
||||
"continue_claimed_task": True,
|
||||
"claim_new_task": False,
|
||||
"write_remote_state": False,
|
||||
}
|
||||
if degraded != expected_degraded:
|
||||
errors.append("degraded_mode 必须禁止领取新任务和写入远端状态。")
|
||||
|
||||
for value, name in path_values:
|
||||
validate_repo_path(root, value, name, errors)
|
||||
find_sensitive_keys(root_object, "manifest", errors)
|
||||
return errors
|
||||
|
||||
|
||||
def manifest_summary(root: Path) -> tuple[int, int]:
|
||||
manifest = json.loads(
|
||||
(root / "docs" / "agent-context.json").read_text(encoding="utf-8")
|
||||
)
|
||||
paths = {manifest["schema"]}
|
||||
paths.update(manifest["bootstrap"]["always_read"])
|
||||
for values in manifest["routes"].values():
|
||||
paths.update(values)
|
||||
paths.update(manifest["tasks"].values())
|
||||
return len(manifest["routes"]), len(paths)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="校验 Agent 上下文清单。")
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parents[1],
|
||||
help="仓库根目录;默认取脚本上一级。",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
root = args.root.resolve()
|
||||
if not root.is_dir():
|
||||
print("ERROR: 仓库根目录不存在。", file=sys.stderr)
|
||||
return 2
|
||||
errors = validate_manifest(root)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
route_count, path_count = manifest_summary(root)
|
||||
print(
|
||||
"agent-context 校验通过:"
|
||||
f"{route_count} 个任务路由,{path_count} 个有效仓库路径。"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,685 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline governance checks for a Harness Coding repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Iterable
|
||||
|
||||
from validate_agent_context import validate_manifest
|
||||
|
||||
|
||||
TASK_ID = re.compile(r"^T-\d{3}[a-z]?$")
|
||||
SHA40 = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
MARKDOWN_LINK = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
|
||||
URI_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
||||
AUTH_VALUE = re.compile(
|
||||
r"(?i)authorization\s*[:=]\s*['\"]?(?:basic|bearer|token)\s+[A-Za-z0-9._~+/=-]{8,}"
|
||||
)
|
||||
TOKEN_ASSIGNMENT = re.compile(
|
||||
r"(?i)^\s*\{?\s*(?:(?:export\s+)?(?:\$env:)?GITEA_TOKEN|['\"]GITEA_TOKEN['\"])"
|
||||
r"\s*[:=]\s*(.*?)\s*[,}]?\s*$"
|
||||
)
|
||||
URL_CREDENTIAL = re.compile(r"(?i)https?://[^/\s:@]+:[^/\s@]+@")
|
||||
GITEA_TOKEN_LITERAL = re.compile(r"\bgta_[A-Za-z0-9_-]{16,}\b")
|
||||
CMD_TOKEN_ASSIGNMENT = re.compile(
|
||||
r"(?ix)^\s*(?:"
|
||||
r"setx\s+(?:\"GITEA_TOKEN\"|GITEA_TOKEN)\s+(?:\"([^\"]*)\"|(.*?))"
|
||||
r"|set\s+(?:\"GITEA_TOKEN\s*=\s*([^\"]*)\"|GITEA_TOKEN\s*=\s*(.*?))"
|
||||
r")\s*$"
|
||||
)
|
||||
DOTNET_TOKEN_SETTER = re.compile(
|
||||
r"(?is)\[Environment\]::SetEnvironmentVariable\s*\(\s*['\"]GITEA_TOKEN['\"]"
|
||||
r"\s*,\s*(['\"])(.*?)\1"
|
||||
)
|
||||
SAFE_VARIABLE_REFERENCE = re.compile(
|
||||
r"(?i)(?:\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*|"
|
||||
r"\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z_][A-Za-z0-9_]*%)"
|
||||
)
|
||||
TASK_REQUIRED_FIELDS = {
|
||||
"id",
|
||||
"title",
|
||||
"phase",
|
||||
"deps",
|
||||
"status",
|
||||
"created",
|
||||
"issue",
|
||||
"context_ref",
|
||||
"claim_branch",
|
||||
"work_branch",
|
||||
"write_paths",
|
||||
}
|
||||
TASK_REQUIRED_SECTIONS = {
|
||||
"问题 / 背景",
|
||||
"方案",
|
||||
"验收要点",
|
||||
"边界(不改什么)",
|
||||
"协作约束",
|
||||
"执行记录",
|
||||
}
|
||||
VALID_STATUS = {"TODO", "DOING", "DONE", "BLOCKED"}
|
||||
ACTIVE_STATUS = {"DOING", "BLOCKED"}
|
||||
KNOWN_TEXT_SUFFIXES = {
|
||||
".md",
|
||||
".py",
|
||||
".ps1",
|
||||
".sh",
|
||||
".json",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".txt",
|
||||
".env",
|
||||
".example",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class Finding:
|
||||
rule: str
|
||||
path: str
|
||||
line: int
|
||||
message: str
|
||||
|
||||
def render(self) -> str:
|
||||
location = self.path if self.line <= 0 else f"{self.path}:{self.line}"
|
||||
return f"ERROR [{self.rule}] {location}: {self.message}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
path: Path
|
||||
metadata: dict[str, Any]
|
||||
body: str
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
value = self.metadata.get("id")
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
value = self.metadata.get("status")
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def relative(path: Path, root: Path) -> str:
|
||||
return path.relative_to(root).as_posix()
|
||||
|
||||
|
||||
def read_text(path: Path) -> str | None:
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
if data.startswith((b"\xff\xfe", b"\xfe\xff")):
|
||||
return data.decode("utf-16")
|
||||
return data.decode("utf-8-sig")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def candidate_files(root: Path) -> list[Path]:
|
||||
command = [
|
||||
"git",
|
||||
"-C",
|
||||
str(root),
|
||||
"ls-files",
|
||||
"--cached",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
"-z",
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
names = [name for name in result.stdout.decode("utf-8").split("\0") if name]
|
||||
return sorted(root / PurePosixPath(name) for name in names if (root / name).is_file())
|
||||
except (OSError, subprocess.CalledProcessError, UnicodeDecodeError):
|
||||
return sorted(
|
||||
path for path in root.rglob("*") if path.is_file() and ".git" not in path.parts
|
||||
)
|
||||
|
||||
|
||||
def parse_scalar(value: str) -> Any:
|
||||
value = value.split(" #", 1)[0].strip()
|
||||
if not value or value.lower() in {"null", "~"}:
|
||||
return None
|
||||
if value == "[]":
|
||||
return []
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
inner = value[1:-1].strip()
|
||||
return [] if not inner else [parse_scalar(item) for item in inner.split(",")]
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
value = value[1:-1]
|
||||
if value.isdigit():
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
|
||||
def parse_frontmatter(path: Path) -> tuple[dict[str, Any], str, list[str]]:
|
||||
text = read_text(path)
|
||||
if text is None:
|
||||
return {}, "", ["文件不是 UTF-8 文本。"]
|
||||
return parse_frontmatter_text(text)
|
||||
|
||||
|
||||
def parse_frontmatter_text(text: str) -> tuple[dict[str, Any], str, list[str]]:
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return {}, text, ["缺少起始 frontmatter 分隔符。"]
|
||||
try:
|
||||
end = next(index for index in range(1, len(lines)) if lines[index].strip() == "---")
|
||||
except StopIteration:
|
||||
return {}, text, ["缺少结束 frontmatter 分隔符。"]
|
||||
|
||||
metadata: dict[str, Any] = {}
|
||||
current_list: str | None = None
|
||||
errors: list[str] = []
|
||||
for number, raw in enumerate(lines[1:end], start=2):
|
||||
if not raw.strip() or raw.lstrip().startswith("#"):
|
||||
continue
|
||||
item = re.match(r"^\s+-\s+(.+)$", raw)
|
||||
if item and current_list:
|
||||
metadata[current_list].append(parse_scalar(item.group(1)))
|
||||
continue
|
||||
field = re.match(r"^([A-Za-z_][A-Za-z0-9_-]*):(?:\s*(.*))?$", raw)
|
||||
if not field:
|
||||
errors.append(f"frontmatter 第 {number} 行语法不受支持。")
|
||||
current_list = None
|
||||
continue
|
||||
key, raw_value = field.groups()
|
||||
if key in metadata:
|
||||
errors.append(f"frontmatter 字段重复:{key}。")
|
||||
value = parse_scalar(raw_value or "")
|
||||
if value is None and not (raw_value or "").strip():
|
||||
value = []
|
||||
current_list = key
|
||||
else:
|
||||
current_list = None
|
||||
metadata[key] = value
|
||||
return metadata, "\n".join(lines[end + 1 :]), errors
|
||||
|
||||
|
||||
def is_safe_repo_path(value: str) -> bool:
|
||||
path = PurePosixPath(value)
|
||||
return bool(value) and value == value.strip() and not (
|
||||
path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or "\\" in value
|
||||
or URI_SCHEME.match(value)
|
||||
or "【" in value
|
||||
or any(character in value for character in "*?[]{}")
|
||||
or any(ord(character) < 32 for character in value)
|
||||
)
|
||||
|
||||
|
||||
def normalize_scope(value: str) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
unicodedata.normalize("NFC", part).casefold()
|
||||
for part in PurePosixPath(value.rstrip("/")).parts
|
||||
if part not in {"."}
|
||||
)
|
||||
|
||||
|
||||
def scopes_overlap(left: str, right: str) -> bool:
|
||||
left_parts = normalize_scope(left)
|
||||
right_parts = normalize_scope(right)
|
||||
if not left_parts or not right_parts:
|
||||
return True
|
||||
width = min(len(left_parts), len(right_parts))
|
||||
return left_parts[:width] == right_parts[:width]
|
||||
|
||||
|
||||
def section_content(body: str, heading: str) -> str:
|
||||
pattern = re.compile(
|
||||
rf"(?ms)^##\s+{re.escape(heading)}\s*$\n(.*?)(?=^##\s+|\Z)"
|
||||
)
|
||||
match = pattern.search(body)
|
||||
return "" if match is None else match.group(1).strip()
|
||||
|
||||
|
||||
def validate_tasks(root: Path) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
task_dir = root / "docs" / "tasks"
|
||||
template = task_dir / "_template.md"
|
||||
if template.is_file():
|
||||
metadata, body, errors = parse_frontmatter(template)
|
||||
for message in errors:
|
||||
findings.append(Finding("task-template", relative(template, root), 0, message))
|
||||
missing = sorted(TASK_REQUIRED_FIELDS - set(metadata))
|
||||
if missing:
|
||||
findings.append(
|
||||
Finding(
|
||||
"task-template",
|
||||
relative(template, root),
|
||||
0,
|
||||
"缺少字段:" + ", ".join(missing),
|
||||
)
|
||||
)
|
||||
headings = set(re.findall(r"(?m)^##\s+(.+?)\s*$", body))
|
||||
missing_sections = sorted(TASK_REQUIRED_SECTIONS - headings)
|
||||
if missing_sections:
|
||||
findings.append(
|
||||
Finding(
|
||||
"task-template",
|
||||
relative(template, root),
|
||||
0,
|
||||
"缺少章节:" + ", ".join(missing_sections),
|
||||
)
|
||||
)
|
||||
else:
|
||||
findings.append(Finding("task-template", "docs/tasks/_template.md", 0, "文件不存在。"))
|
||||
|
||||
tasks: dict[str, Task] = {}
|
||||
issue_numbers: dict[int, str] = {}
|
||||
for path in sorted(task_dir.glob("T-*.md")) if task_dir.is_dir() else []:
|
||||
rel = relative(path, root)
|
||||
metadata, body, errors = parse_frontmatter(path)
|
||||
for message in errors:
|
||||
findings.append(Finding("task-frontmatter", rel, 0, message))
|
||||
filename_id = path.stem
|
||||
if not TASK_ID.fullmatch(filename_id):
|
||||
findings.append(Finding("task-id", rel, 0, "文件名必须是 T-<三位编号>[可选小写后缀]。"))
|
||||
missing = sorted(TASK_REQUIRED_FIELDS - set(metadata))
|
||||
if missing:
|
||||
findings.append(
|
||||
Finding("task-frontmatter", rel, 0, "缺少字段:" + ", ".join(missing))
|
||||
)
|
||||
task_id = metadata.get("id")
|
||||
if task_id != filename_id:
|
||||
findings.append(Finding("task-id", rel, 0, "frontmatter id 必须与文件名一致。"))
|
||||
if isinstance(task_id, str) and task_id in tasks:
|
||||
findings.append(Finding("task-id", rel, 0, "任务 ID 重复。"))
|
||||
status = metadata.get("status")
|
||||
if status not in VALID_STATUS:
|
||||
findings.append(Finding("task-status", rel, 0, "status 不在允许枚举中。"))
|
||||
title = metadata.get("title")
|
||||
if not isinstance(title, str) or not title.strip() or "【" in title:
|
||||
findings.append(Finding("task-metadata", rel, 0, "title 必须是已填写的非空字符串。"))
|
||||
phase = metadata.get("phase")
|
||||
if type(phase) is not int or phase < 0:
|
||||
findings.append(Finding("task-metadata", rel, 0, "phase 必须是非负整数。"))
|
||||
created = metadata.get("created")
|
||||
try:
|
||||
if not isinstance(created, str):
|
||||
raise ValueError
|
||||
date.fromisoformat(created)
|
||||
except ValueError:
|
||||
findings.append(Finding("task-metadata", rel, 0, "created 必须是 YYYY-MM-DD。"))
|
||||
deps = metadata.get("deps")
|
||||
if not isinstance(deps, list) or not all(isinstance(dep, str) for dep in deps):
|
||||
findings.append(Finding("task-deps", rel, 0, "deps 必须是任务 ID 数组。"))
|
||||
elif task_id in deps:
|
||||
findings.append(Finding("task-deps", rel, 0, "任务不得依赖自身。"))
|
||||
elif any(not TASK_ID.fullmatch(dep) for dep in deps):
|
||||
findings.append(Finding("task-deps", rel, 0, "deps 含无效任务 ID。"))
|
||||
write_paths = metadata.get("write_paths")
|
||||
if not isinstance(write_paths, list) or not write_paths:
|
||||
findings.append(Finding("task-scope", rel, 0, "write_paths 必须是非空数组。"))
|
||||
else:
|
||||
values = [value for value in write_paths if isinstance(value, str)]
|
||||
if len(values) != len(write_paths) or any(not is_safe_repo_path(value) for value in values):
|
||||
findings.append(Finding("task-scope", rel, 0, "write_paths 含不安全或非字符串路径。"))
|
||||
if len(values) != len(set(values)):
|
||||
findings.append(Finding("task-scope", rel, 0, "write_paths 含重复路径。"))
|
||||
if rel not in values:
|
||||
findings.append(Finding("task-scope", rel, 0, "write_paths 必须包含任务文件自身。"))
|
||||
issue = metadata.get("issue")
|
||||
if issue is not None and (type(issue) is not int or issue <= 0):
|
||||
findings.append(Finding("task-issue", rel, 0, "issue 必须是正整数或 null。"))
|
||||
elif type(issue) is int:
|
||||
if issue in issue_numbers:
|
||||
findings.append(Finding("task-issue", rel, 0, "Issue 编号与其他任务重复。"))
|
||||
issue_numbers[issue] = filename_id
|
||||
context_ref = metadata.get("context_ref")
|
||||
if context_ref is not None and (
|
||||
not isinstance(context_ref, str) or not SHA40.fullmatch(context_ref)
|
||||
):
|
||||
findings.append(Finding("task-claim", rel, 0, "context_ref 必须是 40 位 SHA 或 null。"))
|
||||
claim_branch = metadata.get("claim_branch")
|
||||
if claim_branch is not None and claim_branch != f"claims/{filename_id}":
|
||||
findings.append(Finding("task-claim", rel, 0, "claim_branch 与任务 ID 不一致。"))
|
||||
work_branch = metadata.get("work_branch")
|
||||
if work_branch is not None and (
|
||||
not isinstance(work_branch, str)
|
||||
or not re.fullmatch(rf"agent/[^/]+/{re.escape(filename_id)}", work_branch)
|
||||
):
|
||||
findings.append(Finding("task-claim", rel, 0, "work_branch 格式或任务 ID 不一致。"))
|
||||
if status == "TODO":
|
||||
for key in ("context_ref", "claim_branch", "work_branch"):
|
||||
if metadata.get(key) is not None:
|
||||
findings.append(Finding("task-claim", rel, 0, f"TODO 的 {key} 必须为 null。"))
|
||||
if issue is not None and status in ACTIVE_STATUS:
|
||||
if not isinstance(context_ref, str) or not SHA40.fullmatch(context_ref):
|
||||
findings.append(Finding("task-claim", rel, 0, "Gitea 活跃任务缺少 40 位 context_ref。"))
|
||||
if metadata.get("claim_branch") != f"claims/{filename_id}":
|
||||
findings.append(Finding("task-claim", rel, 0, "claim_branch 与任务 ID 不一致。"))
|
||||
if not isinstance(work_branch, str) or not work_branch.endswith(f"/{filename_id}"):
|
||||
findings.append(Finding("task-claim", rel, 0, "work_branch 与任务 ID 不一致。"))
|
||||
headings = set(re.findall(r"(?m)^##\s+(.+?)\s*$", body))
|
||||
missing_sections = sorted(TASK_REQUIRED_SECTIONS - headings)
|
||||
if missing_sections:
|
||||
findings.append(
|
||||
Finding("task-sections", rel, 0, "缺少章节:" + ", ".join(missing_sections))
|
||||
)
|
||||
if status == "DONE":
|
||||
evidence = section_content(body, "执行记录")
|
||||
if not evidence or "(做完在此记录" in evidence or "【" in evidence:
|
||||
findings.append(Finding("task-evidence", rel, 0, "DONE 缺少真实执行证据。"))
|
||||
if isinstance(task_id, str):
|
||||
tasks[task_id] = Task(path, metadata, body)
|
||||
|
||||
for task_id, task in sorted(tasks.items()):
|
||||
rel = relative(task.path, root)
|
||||
deps = task.metadata.get("deps")
|
||||
if not isinstance(deps, list):
|
||||
continue
|
||||
for dep in deps:
|
||||
if dep not in tasks:
|
||||
findings.append(Finding("task-deps", rel, 0, f"依赖任务不存在:{dep}。"))
|
||||
elif task.status != "TODO" and tasks[dep].status != "DONE":
|
||||
findings.append(Finding("task-deps", rel, 0, f"非 TODO 任务依赖尚未 DONE:{dep}。"))
|
||||
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
|
||||
def visit(task_id: str) -> None:
|
||||
if task_id in visiting:
|
||||
findings.append(
|
||||
Finding("task-deps", relative(tasks[task_id].path, root), 0, "依赖图存在环。")
|
||||
)
|
||||
return
|
||||
if task_id in visited:
|
||||
return
|
||||
visiting.add(task_id)
|
||||
deps = tasks[task_id].metadata.get("deps")
|
||||
if isinstance(deps, list):
|
||||
for dep in deps:
|
||||
if dep in tasks:
|
||||
visit(dep)
|
||||
visiting.remove(task_id)
|
||||
visited.add(task_id)
|
||||
|
||||
for task_id in sorted(tasks):
|
||||
visit(task_id)
|
||||
|
||||
active = [task for task in tasks.values() if task.status in ACTIVE_STATUS]
|
||||
for index, left in enumerate(sorted(active, key=lambda task: task.task_id)):
|
||||
left_paths = left.metadata.get("write_paths", [])
|
||||
if not isinstance(left_paths, list):
|
||||
continue
|
||||
for right in sorted(active, key=lambda task: task.task_id)[index + 1 :]:
|
||||
right_paths = right.metadata.get("write_paths", [])
|
||||
if not isinstance(right_paths, list):
|
||||
continue
|
||||
if any(
|
||||
isinstance(a, str) and isinstance(b, str) and scopes_overlap(a, b)
|
||||
for a in left_paths
|
||||
for b in right_paths
|
||||
):
|
||||
findings.append(
|
||||
Finding(
|
||||
"task-scope-overlap",
|
||||
relative(right.path, root),
|
||||
0,
|
||||
f"活跃任务与 {left.task_id} 的 write_paths 重叠。",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def markdown_targets(text: str) -> Iterable[tuple[int, str]]:
|
||||
in_fence = False
|
||||
for line_number, line in enumerate(text.splitlines(), start=1):
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("```") or stripped.startswith("~~~"):
|
||||
in_fence = not in_fence
|
||||
continue
|
||||
if in_fence:
|
||||
continue
|
||||
for match in MARKDOWN_LINK.finditer(line):
|
||||
yield line_number, match.group(1).strip()
|
||||
|
||||
|
||||
def clean_link_target(raw: str) -> str | None:
|
||||
if raw.startswith("<") and ">" in raw:
|
||||
target = raw[1 : raw.index(">")]
|
||||
else:
|
||||
target = raw.split(maxsplit=1)[0]
|
||||
target = urllib.parse.unquote(target).split("#", 1)[0].split("?", 1)[0]
|
||||
if (
|
||||
not target
|
||||
or target.startswith("#")
|
||||
or target.startswith("//")
|
||||
or URI_SCHEME.match(target)
|
||||
or "【" in target
|
||||
):
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def validate_markdown_links(root: Path, files: list[Path]) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
for path in files:
|
||||
if path.suffix.lower() != ".md":
|
||||
continue
|
||||
text = read_text(path)
|
||||
if text is None:
|
||||
continue
|
||||
for line, raw in markdown_targets(text):
|
||||
target = clean_link_target(raw)
|
||||
if target is None:
|
||||
continue
|
||||
resolved = root / target.lstrip("/") if target.startswith("/") else path.parent / target
|
||||
try:
|
||||
resolved.resolve().relative_to(root.resolve())
|
||||
except ValueError:
|
||||
findings.append(
|
||||
Finding("markdown-link", relative(path, root), line, "链接逃出仓库根目录。")
|
||||
)
|
||||
continue
|
||||
if not resolved.exists():
|
||||
findings.append(
|
||||
Finding("markdown-link", relative(path, root), line, "本地链接目标不存在。")
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def validate_navigation(root: Path) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
root_readme = read_text(root / "README.md") or ""
|
||||
docs_readme = read_text(root / "docs" / "README.md") or ""
|
||||
for doc in sorted((root / "docs").glob("*.md")):
|
||||
root_target = f"docs/{doc.name}"
|
||||
if root_target not in root_readme:
|
||||
findings.append(Finding("navigation", "README.md", 0, f"未登记 {root_target}。"))
|
||||
if doc.name != "README.md" and f"({doc.name})" not in docs_readme:
|
||||
findings.append(
|
||||
Finding("navigation", "docs/README.md", 0, f"未登记 {doc.name}。")
|
||||
)
|
||||
|
||||
required_root_entries = (
|
||||
"scripts/validate_agent_context.py",
|
||||
"scripts/setup_gitea_labels.py",
|
||||
"scripts/validate_harness_governance.py",
|
||||
"scripts/audit_gitea_coordination.py",
|
||||
"scripts/test_gitea_claim_race.py",
|
||||
"tests/test_governance.py",
|
||||
".gitea/ISSUE_TEMPLATE/task.md",
|
||||
".gitea/PULL_REQUEST_TEMPLATE.md",
|
||||
".gitea/workflows/harness-governance.yml",
|
||||
)
|
||||
for entry in required_root_entries:
|
||||
if entry not in root_readme:
|
||||
findings.append(Finding("navigation", "README.md", 0, f"未登记 {entry}。"))
|
||||
return findings
|
||||
|
||||
|
||||
def safe_token_assignment(value: str) -> bool:
|
||||
value = value.strip().rstrip(",}").strip().strip("'\"")
|
||||
upper = value.upper()
|
||||
return (
|
||||
not value
|
||||
or value.startswith(("【", "<"))
|
||||
or SAFE_VARIABLE_REFERENCE.fullmatch(value) is not None
|
||||
or upper in {"REPLACE", "CHANGEME", "EXAMPLE"}
|
||||
or upper.startswith(("REPLACE_", "CHANGEME_", "EXAMPLE_"))
|
||||
)
|
||||
|
||||
|
||||
def validate_secrets(root: Path, files: list[Path]) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
for path in files:
|
||||
rel = relative(path, root)
|
||||
lower_name = path.name.lower()
|
||||
if lower_name == "gitea.env" or (
|
||||
lower_name.startswith("gitea.env.") and lower_name != "gitea.env.example"
|
||||
):
|
||||
findings.append(Finding("secret-file", rel, 0, "私有 Gitea 环境文件不得被跟踪。"))
|
||||
text = read_text(path)
|
||||
if text is None:
|
||||
if path.suffix.lower() in KNOWN_TEXT_SUFFIXES or lower_name in {
|
||||
".env",
|
||||
"dockerfile",
|
||||
"makefile",
|
||||
}:
|
||||
findings.append(
|
||||
Finding("secret-scan", rel, 0, "已跟踪文本无法安全解码并扫描。")
|
||||
)
|
||||
continue
|
||||
for line_number, line in enumerate(text.splitlines(), start=1):
|
||||
token_assignment = TOKEN_ASSIGNMENT.match(line)
|
||||
cmd_assignment = CMD_TOKEN_ASSIGNMENT.match(line)
|
||||
rules = []
|
||||
if token_assignment and not safe_token_assignment(token_assignment.group(1)):
|
||||
rules.append("GITEA_TOKEN 实值")
|
||||
if cmd_assignment:
|
||||
cmd_value = next(
|
||||
(value for value in cmd_assignment.groups() if value is not None),
|
||||
"",
|
||||
)
|
||||
if not safe_token_assignment(cmd_value):
|
||||
rules.append("Windows 命令 Token 实值")
|
||||
if AUTH_VALUE.search(line):
|
||||
rules.append("Authorization 实值")
|
||||
if URL_CREDENTIAL.search(line):
|
||||
rules.append("URL 内嵌凭据")
|
||||
if GITEA_TOKEN_LITERAL.search(line):
|
||||
rules.append("Gitea Token 字面值")
|
||||
for rule in rules:
|
||||
findings.append(Finding("secret-value", rel, line_number, f"检测到{rule}。"))
|
||||
for match in DOTNET_TOKEN_SETTER.finditer(text):
|
||||
if not safe_token_assignment(match.group(2)):
|
||||
line_number = text.count("\n", 0, match.start()) + 1
|
||||
findings.append(
|
||||
Finding(
|
||||
"secret-value",
|
||||
rel,
|
||||
line_number,
|
||||
"检测到 .NET 环境变量 Token 实值。",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def require_markers(root: Path, path_string: str, markers: Iterable[str]) -> list[Finding]:
|
||||
path = root / PurePosixPath(path_string)
|
||||
if not path.is_file():
|
||||
return [Finding("required-artifact", path_string, 0, "文件不存在。")]
|
||||
text = read_text(path) or ""
|
||||
return [
|
||||
Finding("required-artifact", path_string, 0, f"缺少标记:{marker}。")
|
||||
for marker in markers
|
||||
if marker not in text
|
||||
]
|
||||
|
||||
|
||||
def validate_gitea_artifacts(root: Path) -> list[Finding]:
|
||||
findings = []
|
||||
findings.extend(
|
||||
require_markers(
|
||||
root,
|
||||
".gitea/ISSUE_TEMPLATE/task.md",
|
||||
("task_id:", "task_file:", "context_ref:", "write_paths:", "lease_until:"),
|
||||
)
|
||||
)
|
||||
findings.extend(
|
||||
require_markers(
|
||||
root,
|
||||
".gitea/PULL_REQUEST_TEMPLATE.md",
|
||||
("Closes #", "task_file:", "context_ref:", "write_paths:", "验证证据"),
|
||||
)
|
||||
)
|
||||
workflow = ".gitea/workflows/harness-governance.yml"
|
||||
findings.extend(
|
||||
require_markers(
|
||||
root,
|
||||
workflow,
|
||||
(
|
||||
"push:",
|
||||
"pull_request:",
|
||||
"actions/checkout@v4",
|
||||
"permissions: read-all",
|
||||
"persist-credentials: false",
|
||||
"python -m unittest discover",
|
||||
"python scripts/validate_harness_governance.py",
|
||||
),
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def validate_repository(root: Path) -> list[Finding]:
|
||||
files = candidate_files(root)
|
||||
findings = [
|
||||
Finding("agent-context", "docs/agent-context.json", 0, message)
|
||||
for message in validate_manifest(root)
|
||||
]
|
||||
findings.extend(validate_navigation(root))
|
||||
findings.extend(validate_markdown_links(root, files))
|
||||
findings.extend(validate_tasks(root))
|
||||
findings.extend(validate_secrets(root, files))
|
||||
findings.extend(validate_gitea_artifacts(root))
|
||||
return sorted(set(findings))
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="离线校验 Harness Coding 仓库治理工件。")
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parents[1],
|
||||
help="仓库根目录;默认取脚本上一级。",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
root = args.root.resolve()
|
||||
if not root.is_dir():
|
||||
print("ERROR: 仓库根目录不存在。", file=sys.stderr)
|
||||
return 2
|
||||
findings = validate_repository(root)
|
||||
if findings:
|
||||
for finding in findings:
|
||||
print(finding.render(), file=sys.stderr)
|
||||
print(f"治理校验失败:{len(findings)} 项不一致。", file=sys.stderr)
|
||||
return 1
|
||||
print("治理校验通过:上下文、导航、链接、任务、模板、工作流与敏感信息均一致。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user