Files
rune/scripts/file_drift_issue.py
T
Nick 4eefc15837
CI Pipeline / best-practices (push) Successful in 8s
Auto-version and Release / release (push) Failing after 13s
CI Pipeline / documentation (push) Successful in 5s
CI Pipeline / canary (push) Successful in 33s
CI Pipeline / lint (push) Failing after 41s
CI Pipeline / type-check (push) Successful in 27s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / unit-tests (push) Successful in 59s
CI Pipeline / required-checks (push) Successful in 1s
deploy / deploy (push) Successful in 37s
fix(lifecycle+auth): canonical gitea auth path, terminalization helper, fault-injection gaps (T15 Parts A-E)
- scripts/gitea_auth.py: single credential ladder (env -> 0600 token file ->
  git credential fill); 6 consumers migrated off direct os.environ reads
- rune_operator: consolidate 5 duplicated failure blocks into
  _terminalize_execution_failure; fix 3 fault-injection gaps:
  (1) dual-module PreparedTool identity split crashed approval-path saves,
  (2) fallback-model failure escaped send_task (created/pending orphan),
  (3) running-transition save uncaught (running-with-no-owner)
- envelope task.status now agrees with canonical ledger (awaiting_approval)
- orphan task_ac35f4e287c8 terminalized failed with durable evidence
- tests: gitea auth ladder (6), lifecycle fault matrix (9 boundaries +
  restart/reload + helper contract); 1280 green in ci-venv; ratchet 352/80
2026-09-11 20:15:57 +01:00

172 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""Maintenance drift reconciliation — file or update a deduped drift issue (#192).
Called by ``.gitea/workflows/maintenance.yml`` when
``scripts/version_convergence.py`` reports drift. Advisory only: the issue
tells humans/agent to merge the pending bump PR (or land one); this script
never auto-releases without the PR flow (plan Design Decision 3).
Env-driven: GITEA_TOKEN, RELEASE_SERVER_URL, RELEASE_REPO, MASTER_VERSION,
LATEST_TAG, CONVERGENCE_JSON (the checker's stdout).
"""
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
import sys as _sys
_SCRIPT_DIR = str(__import__('pathlib').Path(__file__).resolve().parent)
if _SCRIPT_DIR not in _sys.path:
_sys.path.insert(0, _SCRIPT_DIR)
import gitea_auth
DRIFT_ISSUE_LABEL = "release-drift"
DRIFT_ISSUE_LABEL_COLOR = "ff0000"
def resolve_label_ids(api: Any, label_names: list[str]) -> list[int]:
"""Resolve label NAMES to the integer IDs Gitea's POST /issues requires
(spec-gate finding 6, #192; repo convention per shared/custodian.py).
A label missing from the repo is created first; if that also fails the
label is skipped — the issue still files, just unlabeled.
"""
status, labels = api("/labels?limit=50")
by_name = {l.get("name"): l.get("id") for l in (labels if isinstance(labels, list) else [])}
ids: list[int] = []
for name in label_names:
label_id = by_name.get(name)
if label_id is None:
status, created = api("/labels", "POST", {"name": name, "color": DRIFT_ISSUE_LABEL_COLOR})
label_id = created.get("id") if status in (200, 201) and isinstance(created, dict) else None
if label_id is None:
print(f"label {name!r} unavailable (resolve/create failed, status {status}) — filing without it")
continue
ids.append(label_id)
return ids
def drift_title(master_version: str, latest_tag: str) -> str:
master = f"v{master_version}" if master_version else "(unknown)"
return f"Release drift: master {master} vs tag {latest_tag}"
def drift_body(decision: dict[str, Any], master_version: str, latest_tag: str) -> str:
subjects = "\n".join(f"- `{s}`" for s in decision.get("sample", []))
anomaly = decision.get("anomaly")
anomaly_line = ""
if anomaly == "version_behind_tag":
# Spec-gate finding 3 (#192): master's version is BELOW the latest tag.
anomaly_line = (
"- anomaly: **version_behind_tag** — master's `__version__` is *below* the latest tag\n"
" (a bump merged without riding a release, or a tag moved backwards).\n"
" The safety net deliberately does not act on this state; reconcile manually.\n"
)
elif anomaly:
anomaly_line = f"- anomaly: **{anomaly}**\n"
return (
"The weekly maintenance reconciliation detected that master and the latest release tag have diverged.\n\n"
f"- master `__version__`: **{master_version or '(unknown)'}**\n"
f"- latest tag: **{latest_tag}**\n"
f"- commits since tag: **{decision.get('commits_since_tag', 0)}**\n"
f"- warranted bump (from commit subjects): **{decision.get('warranted_bump') or 'none'}**\n"
f"{anomaly_line}\n"
"Commit subjects since the tag:\n\n"
f"{subjects}\n\n"
"This issue is advisory — the reconciliation never auto-releases without the PR flow.\n"
"Next step: land a version-bump PR (or let the auto-version safety net file one),\n"
"then cut the release. See docs/release-flow.md for the two-path release flow (#192)."
)
def find_open_drift_issue(issues: list[dict[str, Any]], title: str) -> dict[str, Any] | None:
"""First open issue whose title matches exactly (dedupe; PRs are not issues)."""
for issue in issues:
if issue.get("title") == title and "pull_request" not in issue:
return issue
return None
def main(argv: list[str] | None = None, api: Any = None) -> int:
parser = argparse.ArgumentParser(description="File/update the deduped release-drift issue (#192)")
parser.add_argument("--dry-run", action="store_true", help="print title/body without any API call")
parser.add_argument(
"--create-comment", action="store_true", help="comment on the existing issue instead of a no-op update"
)
args = parser.parse_args(argv)
decision = json.loads(os.environ["CONVERGENCE_JSON"])
master_version = os.environ.get("MASTER_VERSION", "")
latest_tag = os.environ.get("LATEST_TAG", "")
title = drift_title(master_version, latest_tag)
body = drift_body(decision, master_version, latest_tag)
if args.dry_run:
print(f"title: {title}")
print("---")
print(body)
return 0
token = gitea_auth.get_token()
base = os.environ["RELEASE_SERVER_URL"] + "/api/v1/repos/" + os.environ["RELEASE_REPO"]
if api is None:
def api( # noqa: F811 — default client when not injected (tests)
path: str, method: str = "GET", data: dict[str, Any] | None = None
) -> tuple[int, dict[str, Any] | list[dict[str, Any]]]:
req = urllib.request.Request(
base + path,
data=json.dumps(data).encode() if data is not None else None,
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
method=method,
)
with urllib.request.urlopen(req, timeout=30) as r:
payload = r.read()
return r.status, (json.loads(payload) if payload else {})
status, issues = api(f"/issues?state=open&type=issues&limit=50&q={urllib.parse.quote(title)}")
if status != 200:
print(f"issue search failed ({status})")
return 1
existing = find_open_drift_issue(issues if isinstance(issues, list) else [], title)
if existing is not None:
print(f"drift issue #{existing['number']} already open — {'commenting' if args.create_comment else 'no-op'}")
if args.create_comment:
st, _ = api(f"/issues/{existing['number']}/comments", "POST", {"body": body})
if st not in (200, 201):
print(f"comment failed ({st})")
return 1
return 0
label_ids = resolve_label_ids(api, [DRIFT_ISSUE_LABEL])
st, created = api(
"/issues",
"POST",
{
"title": title,
"body": body,
"labels": label_ids,
},
)
if st not in (200, 201):
print(f"issue create failed ({st})")
return 1
number = created.get("number") if isinstance(created, dict) else None
print(f"filed drift issue #{number}: {title}")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except (KeyError, json.JSONDecodeError, urllib.error.HTTPError, urllib.error.URLError) as exc:
print(f"file_drift_issue: failed ({type(exc).__name__}: {exc})", file=sys.stderr)
sys.exit(1)