feat(orchestrator): W3 artifacts tree, ancestry, gates and token-free cleanup
Artifacts show the branch tree with ancestry and gate marks. The cleanup runner calls pkgmgr branch cleanup with its real argv and parses its repos[*].items output, refuses any plan with a remote-branch action, marks an item done only when pkgmgr reports it done, and offers no delete for a branch a worktree holds. Every text states what the pkgmgr command does to volumes. Committed below the bar on the operator's word 2026-11-12 (no further per-bundle tests, one gate after the merge): the W3 e2e never ran; the client switches to --local once pkgmgr ships it. 🎯 Fix: 90 % 💥 Break: 6 % 💸 Cost: 30 % ⏱️ Time: 40 % 🛡️ Security: 100 % 🏆 Score: 100 🚦 Gate: ⛔ break 6 % > 5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fd8fc08603
commit
f7a59d43a5
33 files changed
+1998
-107
No files matched your search
@@ -9,6 +9,7 @@ from .cleanup import cleanup_request
|
||||
from .denying import DENIABLE, DENY_KEYS, denial_note, request_status, request_words
|
||||
from .errors import Conflict, Missing, ToolError
|
||||
from .fields import decode, expect, field
|
||||
from .gating import gate_request
|
||||
from .offices import office_request
|
||||
from .records import digest, shown
|
||||
from .register import REGISTER, adder, operator_rows, read_register, runnable
|
||||
@@ -250,6 +251,8 @@ class Approving:
|
||||
signed = self.appointable(raw)
|
||||
elif task_request(raw):
|
||||
signed = self.task_changes(raw)
|
||||
elif gate_request(raw):
|
||||
signed = self.gate_changes(raw)
|
||||
else:
|
||||
signed = [item["signed"] for item in self.checked(raw) if item["signed"] is not None]
|
||||
return self.challenge_for(signed)
|
||||
|
||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
|
||||
from .records import epoch, iso_epoch, read_json, shown
|
||||
from .register import ENDED, status_of
|
||||
from .signed import signing
|
||||
from .tree import inside, owns
|
||||
|
||||
PUSH = re.compile(r"\b(?:push|gipsn|git-sign-push)\b")
|
||||
@@ -105,7 +106,8 @@ def enrich(scan: dict, tasks: dict[str, dict], reviews: list[dict], touched: Tou
|
||||
shared = sorted(files[a] & files[b])
|
||||
if shared:
|
||||
overlaps.append({"a": a, "b": b, "count": len(shared), "files": shared[:SHARED_SHOWN]})
|
||||
merges = [{"from": item["name"], "into": item["merged_into"] or entry.get("default") or "", "by": item["merged_by"]} for item in entry["branches"] if item["merged_by"]]
|
||||
default = entry.get("default") or ""
|
||||
merges = [{"from": item["name"], "into": default if signing.names_default(item["merged_into"], default) else item["merged_into"] or default, "by": item["merged_by"]} for item in entry["branches"] if item["merged_by"]]
|
||||
repos.append({**entry, "branches": branches, "overlaps": overlaps, "merges": merges})
|
||||
loose = [{"task": task, "title": shown(row.get("title")), "status": status_of(row), "artefacts": artefacts_of(task, row)} for task, row in sorted(tasks.items()) if task not in placed and artefacts_of(task, row)]
|
||||
return {**scan, "repos": repos, "tasks": loose}
|
||||
@@ -11,6 +11,8 @@ from pathlib import Path
|
||||
from .errors import Missing, ToolError
|
||||
from .fields import decode, expect
|
||||
from .records import read_json, stamp
|
||||
from .register import OPERATOR
|
||||
from .skill_scripts import OPERATOR_SCRIPT
|
||||
from .tree import offered
|
||||
|
||||
ITEM_KEYS = ("kind", "repository", "branch", "worktree", "tip")
|
||||
@@ -18,6 +20,10 @@ JOB_KEYS = ("repository", "branch", "worktree", "tip")
|
||||
JOB_VERSION = 1
|
||||
ITEM_LIMIT = 100
|
||||
RESULTS_SHOWN = 20
|
||||
TOKEN_ROW = "pkgmgr-github-token"
|
||||
TOKEN_COMMAND = "env secret-tool store --label=pkgmgr-github service pkgmgr:github username 'github.com|-' </dev/tty"
|
||||
TOKEN_REASON = "Store the GitHub token in the Secret Service under the key pkgmgr reads; Clean all merged then also deletes remote and squash-merged branches."
|
||||
TOKEN_ADDER = "Operator"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -65,19 +71,24 @@ class Cleaning:
|
||||
|
||||
Returns:
|
||||
The signed items, each still a merged-clean branch without a gc
|
||||
skip reason at the same tip in the current tree file.
|
||||
skip reason at the same tip in the current tree file; without
|
||||
the GitHub token (delete_ready false) each also token-free.
|
||||
"""
|
||||
if not isinstance(raw, list) or not 1 <= len(raw) <= ITEM_LIMIT:
|
||||
raise ToolError(f"items must be a list of 1 to {ITEM_LIMIT} objects")
|
||||
scan = self.tree.get()
|
||||
if scan.get("delete_ready") is not True:
|
||||
raise ToolError(f"delete blocked: {scan.get('delete_reason') or scan.get('error') or 'delete_ready is false'}")
|
||||
known = offered(scan)
|
||||
ready = scan.get("delete_ready") is True
|
||||
if scan.get("error"):
|
||||
raise ToolError(f"delete blocked: {scan['error']}")
|
||||
known = offered(scan, ready)
|
||||
locked = [] if ready else offered(scan, True)
|
||||
chosen = []
|
||||
for entry in raw:
|
||||
if not isinstance(entry, dict):
|
||||
raise ToolError("every item must be an object with kind, repository, branch, worktree and tip")
|
||||
expect(entry, ITEM_KEYS)
|
||||
if entry in locked and entry not in known:
|
||||
raise ToolError(f"delete blocked: {scan.get('delete_reason') or 'delete_ready is false'}")
|
||||
if entry not in known:
|
||||
raise ToolError(f"{entry.get('branch')!s:.200} in {entry.get('repository')!s:.200} is no merged-clean branch without skip reason at that tip in the tree file")
|
||||
if entry in chosen:
|
||||
@@ -97,6 +108,19 @@ class Cleaning:
|
||||
self.log.add({"kind": "run", "title": f"Delete signed: {names}"[:200], "text": "\n".join(f"{item['repository']} {item['branch']} @ {item['tip'][:12]}" for item in items), "ref": {"cleanup": job_id, "status": "queued"}})
|
||||
return {"id": job_id, "status": "queued", "items": job["items"]}
|
||||
|
||||
def token_row(self, body: bytes) -> dict:
|
||||
"""Returns:
|
||||
The operator row that stores the GitHub token in the Secret
|
||||
Service under pkgmgr's key, added or refreshed through
|
||||
operator.py; it holds no secret, the operator types the token
|
||||
when running it.
|
||||
"""
|
||||
expect(decode(body), ())
|
||||
done = self.scripts.run(OPERATOR_SCRIPT, ["add", TOKEN_ROW, "--dir", "/", "--command", TOKEN_COMMAND, "--reason", TOKEN_REASON, "--check", "manual", "--session", TOKEN_ADDER])
|
||||
if done["code"] != 0:
|
||||
raise ToolError(f"operator.py add failed ({done['code']}): {done['stderr'].strip()[-500:]}")
|
||||
return {"task": f"{OPERATOR}{TOKEN_ROW}", "status": "queued"}
|
||||
|
||||
def cleanup_state(self, job_id: str) -> dict:
|
||||
result = read_json(self.spool.results / f"{job_id}.json")
|
||||
if isinstance(result, dict) and isinstance(result.get("items"), list):
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import importlib.util
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from urllib.parse import quote
|
||||
|
||||
from .errors import ToolError
|
||||
from .fields import decode, expect
|
||||
from .jobs import GATE_JOB
|
||||
from .signed import signing
|
||||
|
||||
GATES_SCRIPT = "kvb-check-gate/scripts/gates.py"
|
||||
GATES_DB = "gates.db"
|
||||
GATE_TASK = "gates"
|
||||
CHANGE_LIMIT = 100
|
||||
CHANGE_KEYS = ("kind", "argv")
|
||||
|
||||
|
||||
def gate_request(raw: object) -> bool:
|
||||
return isinstance(raw, list) and bool(raw) and all(isinstance(entry, dict) and entry.get("kind") == GATE_JOB for entry in raw)
|
||||
|
||||
|
||||
def shown_command(argv: list[str]) -> str:
|
||||
return " ".join(["gates.py", *argv])
|
||||
|
||||
|
||||
def gates_module(script: Path) -> ModuleType | None:
|
||||
"""Returns:
|
||||
The installed gates.py as a module, for its resolve() and
|
||||
parents_of(), which read only the database; loaded again after an
|
||||
install changed it, None when it is missing or does not load.
|
||||
"""
|
||||
try:
|
||||
changed = script.stat().st_mtime_ns
|
||||
except OSError:
|
||||
return None
|
||||
return loaded(script, changed)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def loaded(script: Path, changed: int) -> ModuleType | None:
|
||||
spec = importlib.util.spec_from_file_location(f"kvb_orchestrator_gates_{changed}", script)
|
||||
if spec is None:
|
||||
return None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except (OSError, ImportError, RuntimeError, SyntaxError):
|
||||
return None
|
||||
return module
|
||||
|
||||
|
||||
def branch_gates(gates: ModuleType, db: sqlite3.Connection, repo: str, branch: str) -> dict:
|
||||
return {
|
||||
"parents": [{"parent": parent, "source": source} for parent, source in gates.parents_of(db, repo, branch)],
|
||||
"rules": dict(db.execute("SELECT action, level FROM rules WHERE repo = ? AND branch = ?", (repo, branch))),
|
||||
"resolved": {action: dict(zip(("level", "source"), gates.resolve(db, repo, branch, action))) for action in gates.ACTIONS},
|
||||
}
|
||||
|
||||
|
||||
def read_gates(gates: ModuleType | None, db: Path, scan: dict) -> dict:
|
||||
"""Args:
|
||||
gates: the gates.py module, or None.
|
||||
db: gates.db under the read-only mount of ~/.local/state/agents-council.
|
||||
scan: the tree; its repositories and branches are the ones shown.
|
||||
|
||||
Returns:
|
||||
{repo: {branch: {parents, rules, resolved}}} as `gates.py show --json`
|
||||
prints it, read with the database opened read-only and without git;
|
||||
{} without the script or a readable database.
|
||||
"""
|
||||
if gates is None or not db.is_file():
|
||||
return {}
|
||||
try:
|
||||
with closing(sqlite3.connect(f"file:{quote(str(db))}?mode=ro", uri=True)) as connection:
|
||||
return {repo["path"]: {entry["name"]: branch_gates(gates, connection, repo["path"], entry["name"]) for entry in repo["branches"]} for repo in scan["repos"]}
|
||||
except sqlite3.Error:
|
||||
return {}
|
||||
|
||||
|
||||
class Gating:
|
||||
def gate_view(self, scan: dict) -> dict:
|
||||
try:
|
||||
return read_gates(gates_module(self.scripts.skills / GATES_SCRIPT), self.kept / GATES_DB, scan)
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
def gate_changes(self, raw: object) -> list[dict]:
|
||||
"""Args:
|
||||
raw: the page's items, each {"kind": "gate", "argv"} with the
|
||||
gates.py arguments of one rule set|drop or parents add|drop;
|
||||
the staged changes go under one passkey assertion.
|
||||
|
||||
Returns:
|
||||
The signed items, each with an argv signing.gate_argv() accepts.
|
||||
"""
|
||||
if not isinstance(raw, list) or not 1 <= len(raw) <= CHANGE_LIMIT:
|
||||
raise ToolError(f"items must be a list of 1 to {CHANGE_LIMIT} gate changes")
|
||||
signed: list[dict] = []
|
||||
for entry in raw:
|
||||
if not isinstance(entry, dict):
|
||||
raise ToolError("every item must be an object with kind and argv")
|
||||
expect(entry, CHANGE_KEYS)
|
||||
try:
|
||||
item = signing.gate_item(signing.gate_argv(entry["argv"]))
|
||||
except signing.Refused as error:
|
||||
raise ToolError(f"gate change: {error}") from None
|
||||
if item in signed:
|
||||
raise ToolError("items must be distinct")
|
||||
signed.append(item)
|
||||
return signed
|
||||
|
||||
def change_gates(self, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("items", "proof"))
|
||||
items = self.gate_changes(payload["items"])
|
||||
proof = self.covered(payload["proof"], items, time.time())
|
||||
for item in items:
|
||||
self.operator_proven(proof, item)
|
||||
ids = []
|
||||
for item in items:
|
||||
fields = {"kind": GATE_JOB, "task": GATE_TASK, "argv": item["argv"], "command": shown_command(item["argv"]), "directory": "", "sha256": "", "status": "queued", "proof": proof}
|
||||
ids.append(self.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})["id"])
|
||||
commands = [shown_command(item["argv"]) for item in items]
|
||||
self.log.add({"kind": "run", "title": f"Gate change signed: {len(items)}", "text": "\n".join(commands), "ref": {"jobs": ids, "status": "queued"}})
|
||||
return {"jobs": ids, "status": "queued"}
|
||||
@@ -40,6 +40,8 @@ CLEANUP_STATE = re.compile(r"/api/cleanup/([0-9a-f]{16})")
|
||||
OFFICES_ROUTE = "/api/offices"
|
||||
OFFICE_STATE = re.compile(r"/api/offices/([0-9a-f]{16})")
|
||||
TASKS_ROUTE = "/api/tasks"
|
||||
GATES_ROUTE = "/api/gates"
|
||||
TOKEN_ROUTE = "/api/cleanup/token"
|
||||
VENDOR_ROUTE = re.compile(r"/vendor/([a-z0-9.-]{1,64})")
|
||||
SCRIPT_TYPE = "text/javascript; charset=utf-8"
|
||||
FONT_TYPE = "font/woff2"
|
||||
@@ -57,7 +59,7 @@ SETTINGS_SIGN_ROUTE = "/api/settings/sign"
|
||||
CHOICES_ROUTE = "/api/choices"
|
||||
CHOICES_SIGN_ROUTE = "/api/choices/sign"
|
||||
WAV_POSTS = (STT_ROUTE, VOICE_ROUTE)
|
||||
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE, CHOICES_ROUTE, CHOICES_SIGN_ROUTE)
|
||||
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE, CHOICES_ROUTE, CHOICES_SIGN_ROUTE, GATES_ROUTE, TOKEN_ROUTE)
|
||||
|
||||
|
||||
class Handler(Exchange):
|
||||
@@ -168,6 +170,10 @@ class Handler(Exchange):
|
||||
return lambda body: self.answer(lambda: hub.appoint(body), HTTPStatus.ACCEPTED)
|
||||
if path == TASKS_ROUTE:
|
||||
return lambda body: self.answer(lambda: hub.change_tasks(body), HTTPStatus.ACCEPTED)
|
||||
if path == GATES_ROUTE:
|
||||
return lambda body: self.answer(lambda: hub.change_gates(body), HTTPStatus.ACCEPTED)
|
||||
if path == TOKEN_ROUTE:
|
||||
return lambda body: self.answer(lambda: hub.token_row(body), HTTPStatus.CREATED)
|
||||
if inbox:
|
||||
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
|
||||
if undo:
|
||||
|
||||
@@ -16,6 +16,7 @@ from .cleanup import Cleaning, Spool
|
||||
from .denying import Denying
|
||||
from .engines import Engines, Speech
|
||||
from .events import Log, Watch
|
||||
from .gating import Gating
|
||||
from .icons import sheet
|
||||
from .inbox import Inbox
|
||||
from .jobs import Jobs
|
||||
@@ -65,7 +66,7 @@ def environment(templates: Path) -> jinja2.Environment:
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hub(Mcp, Reviewing, Asking, Messaging, Choosing, Approving, Denying, Requesting, Cleaning, Offices, Tasking, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
|
||||
class Hub(Mcp, Reviewing, Asking, Messaging, Choosing, Approving, Denying, Requesting, Cleaning, Offices, Tasking, Gating, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
|
||||
council: Path
|
||||
pages: Path
|
||||
public_port: int
|
||||
|
||||
@@ -12,15 +12,17 @@ JOB_OPEN = ("queued", "running")
|
||||
RUN_OUTPUT = 64 << 10
|
||||
CONSOLE = ("id", "status", "code", "output", "created_ts", "started", "finished")
|
||||
TASK_JOB = "task"
|
||||
GATE_JOB = "gate"
|
||||
CHANGES = (TASK_JOB, GATE_JOB)
|
||||
|
||||
|
||||
def runner_job(job: dict) -> dict:
|
||||
"""Returns:
|
||||
What the runner claims: an operator command, or a task change with
|
||||
kind and argv; a runner that predates task changes verifies the
|
||||
latter as an operator command and refuses it.
|
||||
What the runner claims: an operator command, or a task or gate change
|
||||
with kind and argv; a runner that predates such changes verifies them
|
||||
as an operator command and refuses them.
|
||||
"""
|
||||
change = {"kind": TASK_JOB, "argv": job["argv"]} if job.get("kind") == TASK_JOB else {}
|
||||
change = {"kind": job["kind"], "argv": job["argv"]} if job.get("kind") in CHANGES else {}
|
||||
return {**{key: job[key] for key in ("id", "task", "command", "directory", "sha256")}, **change, "proof": job.get("proof")}
|
||||
|
||||
|
||||
@@ -90,7 +92,7 @@ class Jobs:
|
||||
job, changed = self.jobs.transition(job_id, "running", {"status": status, "code": code, "output": output, "finished": time.time()})
|
||||
if not changed:
|
||||
raise Conflict({"id": job_id, "status": job["status"]})
|
||||
command_done = status == "done" and job.get("kind") != TASK_JOB
|
||||
command_done = status == "done" and job.get("kind") not in CHANGES
|
||||
runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if command_done else []
|
||||
tail = "\n".join(output.splitlines()[-20:])
|
||||
self.log.add({"kind": "run", "title": f"{status}: {job['command'][:200]}", "text": tail, "ref": {"job": job_id, "task": job["task"], "code": code, "status": status}})
|
||||
|
||||
@@ -17,6 +17,8 @@ ESTIMATE = "kvb-org-queue-estimate/scripts/estimate.py"
|
||||
|
||||
|
||||
class Runner(Protocol):
|
||||
skills: Path
|
||||
|
||||
def run(self, script: str, args: list[str]) -> dict: ...
|
||||
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ class Sources:
|
||||
reviews = self.reviews.listed()
|
||||
scan = assign(self.tree.get(), tasks, read_claims(self.council), reviews)
|
||||
history = read_jsonl(self.council / "queue-history.jsonl")
|
||||
return {**enrich(scan, tasks, reviews, self.touched, history, self.jobs.listed()), "cleanups": self.cleanup_results()}
|
||||
return {**enrich(scan, tasks, reviews, self.touched, history, self.jobs.listed()), "cleanups": self.cleanup_results(), "gates": self.gate_view(scan)}
|
||||
|
||||
def schedules(self, claims: list[dict], now: float) -> list[dict]:
|
||||
beats = {claim["session"]: claim["beat"] for claim in claims if claim["beat"] is not None}
|
||||
|
||||
@@ -18,6 +18,8 @@ FLOW_SOURCE = re.compile(r"[a-z][a-z0-9-]{0,31}")
|
||||
INVALID = "invalid"
|
||||
# renaming it silently stops the tree timer: kvb-orchestrator-tree.service tests this file's age
|
||||
TREE_HEARTBEAT = "tree-heartbeat"
|
||||
GC_SKILL = "kvb-git-branch-gc"
|
||||
GC_COMPOSE_DOWN = ("docker", "compose", "-p", "{project}", "down", "-v", "--remove-orphans")
|
||||
|
||||
|
||||
def flow(item: dict) -> dict | None:
|
||||
@@ -66,34 +68,47 @@ def worktree(item: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def held_by(worktree: str) -> str:
|
||||
return f"held by worktree {worktree}: pkgmgr branch cleanup skips it, use branch gc ({GC_SKILL}, pkgmgr gc)"
|
||||
|
||||
|
||||
def dry_run(item: dict) -> list[str]:
|
||||
"""Returns:
|
||||
The lines `pkgmgr branch cleanup --local` would act on for one tree
|
||||
item, read from the gc plan the tree file carries; a remote branch
|
||||
always stays.
|
||||
What the runner's `pkgmgr branch cleanup` does for one tree item,
|
||||
read from the gc plan the tree file carries: it deletes the local
|
||||
branch and never touches a worktree, Compose stack or volume; a
|
||||
remote branch stays, the runner refuses a plan that deletes one. A
|
||||
branch held by a worktree it skips; its lines name what branch gc
|
||||
would do instead, its `docker compose down -v` deleting the stack's
|
||||
volumes.
|
||||
"""
|
||||
tip = (item["local_sha"] or item["head"] or "")[:12]
|
||||
merged = f", merged by {item['merged_by']}" if item["merged_by"] else ""
|
||||
lines = [f"{item['branch']} @ {tip}: {item['state']}{merged}"]
|
||||
if item["worktree"]:
|
||||
lines.append(held_by(item["worktree"]))
|
||||
lines.append(f"branch gc would remove the worktree {item['worktree']} and the local branch {item['branch']}")
|
||||
lines += [f"branch gc would run {' '.join(GC_COMPOSE_DOWN).format(project=name)}, which deletes the volumes of {name}" for name in item["compose_projects"]]
|
||||
lines += [f"branch gc would remove the image {name}" for name in item["images"]]
|
||||
return lines
|
||||
for action in item["actions"]:
|
||||
if action == "worktree":
|
||||
lines.append(f"removes the worktree {item['worktree']}")
|
||||
elif action == "local-branch":
|
||||
if action == "local-branch":
|
||||
lines.append(f"deletes the local branch {item['branch']}")
|
||||
elif action.startswith("remote"):
|
||||
lines.append(f"stays: {action} (local only)")
|
||||
lines.append(f"stays: {action} (local only; the host refuses a plan that deletes it)")
|
||||
else:
|
||||
lines.append(action)
|
||||
lines += [f"stops the Compose stack {name} without -v" for name in item["compose_projects"]]
|
||||
lines += [f"removes the image {name}" for name in item["images"]]
|
||||
return lines
|
||||
|
||||
|
||||
def branch(item: dict, path: str) -> dict:
|
||||
def branch(item: dict, path: str, default: object) -> dict:
|
||||
remote = next(iter(item["remotes"].values()), "")
|
||||
head = item["local_sha"] or item["head"] or remote
|
||||
open_to_delete = item["state"] == "merged-clean" and not item["skip"] and bool(item["local_sha"])
|
||||
held = held_by(item["worktree"]) if item["worktree"] and item["state"] == "merged-clean" else ""
|
||||
open_to_delete = item["state"] == "merged-clean" and not item["skip"] and bool(item["local_sha"]) and not item["worktree"]
|
||||
return {
|
||||
"held": held,
|
||||
"token_free": signing.token_free(item, default),
|
||||
"name": item["branch"],
|
||||
"head": head,
|
||||
"upstream": item["upstream"] or "",
|
||||
@@ -116,13 +131,21 @@ def repo(entry: dict) -> dict:
|
||||
return {
|
||||
**{key: value for key, value in entry.items() if key != "items"},
|
||||
"name": "/".join(Path(entry["path"]).parts[-LABEL_PARTS:]),
|
||||
"branches": [branch(item, entry["path"]) for item in items if item["branch"]],
|
||||
"branches": [branch(item, entry["path"], entry.get("default")) for item in items if item["branch"]],
|
||||
"detached": [worktree(item) for item in items if item["detached"]],
|
||||
}
|
||||
|
||||
|
||||
def offered(scan: dict) -> list[dict]:
|
||||
return [entry["cleanup"] for repository in scan["repos"] for entry in repository["branches"] if entry["cleanup"] is not None]
|
||||
def offered(scan: dict, ready: bool) -> list[dict]:
|
||||
"""Args:
|
||||
scan: the tree.
|
||||
ready: whether pkgmgr holds the GitHub token.
|
||||
|
||||
Returns:
|
||||
The cleanup items deletable now: every one with the token, without
|
||||
it only those of token-free branches.
|
||||
"""
|
||||
return [entry["cleanup"] for repository in scan["repos"] for entry in repository["branches"] if entry["cleanup"] is not None and (ready or entry["token_free"])]
|
||||
|
||||
|
||||
def read_tree(path: Path) -> dict:
|
||||
|
||||
Reference in new issue
Block a user