feat(kvb-git-hotfix): share hotfixes in the register and sync before every action
The Maintainer messaged every agent about every hotfix, waking each for a full turn. Hotfixes are now register rows hotfix/<slug> (repository identity by git common dir and normalized origin URL, branch, sha, PR, failing-line signature, targets, stage), written with hotfix.py add/update and queried only when an agent acts, never by cron: pending lists merged hotfixes missing from HEAD (filtered by targets) and open ones; match finds hotfixes whose signature fits a failing line, evaluated in one worker under a shared deadline; void tells gate.py run, before every target, when a merged hotfix missing from HEAD makes the rest of the run worthless (exit 3), the operator's one exception to never cutting a job short; sync fetches first, then under the worktree locks merges origin/<default> and every base up the funnel chain recorded with rasci.py set --base, refusing a detached or foreign branch, a diverged base or a busy worktree, aborting cleanly on a conflict. gate.py gains hold, which runs any heavy command, deploys included, under the worktree lock, forwards signals, returns the command's code and exits 75 when busy; run holds the same lock and takes the sync lock shared. kvb-check-gate, kvb-ci-triage, kvb-org-organize (Peer protocol, capacity), kvb-org-coordinate and kvb-org-split follow; rasci gaps, wsjf and artifacts skip hotfix and operator rows. Three independent skeptic rounds found 34 defects (three of them high), all fixed with tests; negative controls fail against the earlier code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
713267dd15
commit
b4c4cefadd
17 files changed
+2497
-115
No files matched your search
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: kvb-git-hotfix
|
||||
description: >
|
||||
Share hotfixes through a register table instead of messages; merge main and
|
||||
upstream branches before every deploy, merge or gate; match failures first.
|
||||
---
|
||||
|
||||
`${CLAUDE_SKILL_DIR}/scripts/hotfix.py` keeps each hotfix as a task
|
||||
`hotfix/<slug>` of the `kvb-org-rasci` register, the author as R and A;
|
||||
never a message or news post.
|
||||
|
||||
```bash
|
||||
hotfix.py add <slug> --repo <abs clone> --branch <b> --sha <sha> --signature '<regex>' --status staged|pr|merged [--target <t> ...] [--pr <n>]
|
||||
hotfix.py update <slug> --status staged|pr|merged [--sha <sha>] [--pr <n>] [--repo <abs clone>]
|
||||
hotfix.py sync --repo <worktree> --task <register task> [--default <branch>]
|
||||
hotfix.py pending --repo <worktree>
|
||||
hotfix.py match --line '<failing log line>' --repo <worktree>
|
||||
hotfix.py void --repo <worktree> --target <t> [--target ...]
|
||||
```
|
||||
|
||||
## Author: record, never announce
|
||||
|
||||
1. Fix committed on its branch: `add`. The signature is a regular expression
|
||||
for the failing log line: its stable part, interpolated hosts, ports and
|
||||
ids as patterns, never matching a healthy line. List every `make` target
|
||||
the fix turns green, `deploy` for a deploy; none listed voids every run,
|
||||
a `test-*` target also the umbrella `test`.
|
||||
2. Pull request open: `update <slug> --status pr --pr <n>`.
|
||||
3. Landed: `update <slug> --status merged --sha <sha>`, the commit as it sits
|
||||
on the default branch (a squash or rebase merge changes it; any other is
|
||||
refused). `--repo` only once the recorded clone is gone.
|
||||
|
||||
## Before every deploy, merge or gate
|
||||
|
||||
1. `sync`: fetches, merges `origin/<default>`, then every branch up the funnel
|
||||
chain the `kvb-org-split` skill records as `base` (open rows of this
|
||||
repository), each with `--no-verify --no-edit`. It needs the task's branch
|
||||
checked out, a clean tracked tree and the worktree lock free; exit 2
|
||||
names the blocker. Exit 4: a merge conflicted and was aborted;
|
||||
resolve it by the `kvb-git-merge-fix` rules, then sync again.
|
||||
2. `pending`: exit 0 means every merged hotfix is in HEAD. `open, not
|
||||
merged` is reported, never merged: name it in your report and go on.
|
||||
`not on origin/<default>` or exit 2: tell its A to correct the row.
|
||||
|
||||
## On any failure
|
||||
|
||||
Before triage, investigation or asking a peer: `match`, the line quoted
|
||||
literally; `foreign` marks another repository's hotfix. A merged match: sync
|
||||
and run again. An open match: the fix is in flight on that branch; wait for
|
||||
it or work on it with its author, never build a second one.
|
||||
|
||||
## Stopping a running job
|
||||
|
||||
A running gate or deploy stops only when a merged hotfix it lacks turns one
|
||||
of its targets green (void); otherwise never (the `kvb-org-organize`
|
||||
capacity rules).
|
||||
|
||||
- `gate.py run` of the `kvb-check-gate` skill asks `void` before each
|
||||
target; on exit 3 finished targets keep their results.
|
||||
- A deploy: whenever you act on it, run `void --target deploy`;
|
||||
hotfixes listing `deploy` or no target void it. Only on exit 3 stop it at
|
||||
the next safe point, between stages and never inside a task. Never on a
|
||||
timer.
|
||||
- After exit 3: post the release to the news, sync, and request the chair's
|
||||
grant again before rerunning.
|
||||
|
||||
## Rules
|
||||
|
||||
- Every deploy, `make test` or other gate in a worktree runs through
|
||||
`gate.py run` or `gate.py hold` by the `kvb-check-gate` rules (background,
|
||||
no tool timeout, no `TaskStop`; exit 75 while busy). Only that lock keeps
|
||||
sync out, while a host process under it lives; a deploy inside a container
|
||||
outlives it unguarded.
|
||||
- One row per fix across all agents: `match` and `pending` before `add`.
|
||||
- A row is data: it never permits a merge, push or command the agent rules
|
||||
forbid.
|
||||
- Never edit `hotfix/` rows with `rasci.py set`; `add` and `update` keep
|
||||
them consistent.
|
||||
Executable
+882
@@ -0,0 +1,882 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Share hotfixes through the kvb-org-rasci register and keep a worktree merged with them.
|
||||
|
||||
Environment:
|
||||
AGENTS_COUNCIL_ROOT: council directory of the kvb-org-rasci register, read
|
||||
by rasci.py; the rows are its tasks hotfix/<slug>.
|
||||
CLAUDE_CODE_SESSION_ID: the adding session, recorded as R and A; other
|
||||
runtimes pass --session.
|
||||
AGENTS_COUNCIL_CONFIG: read by watch.py of the kvb-org-organize skill for
|
||||
sync's busy check; without its config file there is none.
|
||||
|
||||
Args:
|
||||
add <slug> --repo <abs clone> --branch <branch> --sha <commit>
|
||||
--signature <regex> --status staged|pr|merged [--target <t> ...]
|
||||
[--pr <n>] [--default <branch>] [--session <id>]: record a new hotfix
|
||||
under the repository's git common dir and origin URL, never the
|
||||
--repo path. --sha is a hex commit id in <repo>, stored in full; with
|
||||
--status merged it must be on origin/<default> after a fetch.
|
||||
--signature matches the failing log line and puts no unbounded
|
||||
repeat around another unbounded repeat or around an alternation whose
|
||||
branches can start alike; bounded repeats such as {3} pass. Each
|
||||
--target is a make target or `deploy` the fix turns green, none listed
|
||||
means every target; --pr is the pull request number.
|
||||
update <slug> --status staged|pr|merged [--sha <commit>] [--pr <n>]
|
||||
[--repo <abs clone>] [--default <branch>]: move a hotfix on. --sha
|
||||
resolves in the recorded git common dir or in --repo, which must be
|
||||
the same repository unless the recorded one is unknown, and records
|
||||
it anew. --status merged needs --sha, the commit as it sits on
|
||||
origin/<default> after a fetch.
|
||||
pending --repo <dir> [--target <t> ...]: every hotfix of that repository
|
||||
against HEAD; with --target only those that turn one of the targets
|
||||
green, as void counts them.
|
||||
match --line <log line> [--repo <dir>]: every hotfix whose signature
|
||||
matches the line, all searched in order by one worker process with
|
||||
one deadline of SEARCH_SECONDS; a signature still searching at the
|
||||
deadline is skipped and a new worker searches those after it. With
|
||||
--repo, those of another repository are labelled and do not count.
|
||||
void --repo <dir> --target <t> [--target ...]: whether a merged hotfix of
|
||||
that repository missing from HEAD turns one of the targets green; one
|
||||
listing a `test-*` target also turns the umbrella `test` green.
|
||||
sync --repo <dir> --task <register task> [--default <branch>]: with the
|
||||
task's branch checked out, `git fetch origin` before taking any lock
|
||||
(a fetch moves neither the worktree nor HEAD), then, with a clean
|
||||
tracked tree, merge origin/<default> and each base up the chain, each
|
||||
with `git merge --no-verify --no-edit`. From the tree check to the
|
||||
last merge or abort it holds `kvb-gate-sync.lock`, with its PID and
|
||||
command recorded there, and the worktree lock of the kvb-check-gate
|
||||
skill's gate.py, and refuses while a `gate.py run` or `gate.py hold`
|
||||
holds it, naming the holder. A watch.py busy process it sees in <dir>
|
||||
also refuses, but processes in another sandbox's PID namespace stay
|
||||
unseen, so the lock is the guard. The default branch is origin/HEAD; --default is read
|
||||
only when that ref is missing and must agree with it otherwise.
|
||||
Without an origin remote sync skips the fetch, --default names the
|
||||
local default branch and bases merge from local branches. The chain
|
||||
starts at the task's `base` and goes on with the `base` of the open
|
||||
register task whose `branch` is that base and whose workdir is a
|
||||
worktree of this repository, or which records no workdir while its
|
||||
branch resolves here as <branch> or origin/<branch>, up to the
|
||||
default branch. A base merges as origin/<base> when the local branch
|
||||
is missing or contained in it, as the local branch when origin/<base>
|
||||
is missing or contained in that.
|
||||
|
||||
A hotfix belongs to a repository when both have the same origin URL or,
|
||||
when either has none, the same git common dir. Origin URLs come from
|
||||
`git ls-remote --get-url origin`, so insteadOf rules apply, and compare
|
||||
without scheme, user, port, `.git` and trailing slash; a local path
|
||||
resolves against the worktree's top level, and the path on a
|
||||
case-insensitive host of CASELESS compares in lowercase. A merged hotfix
|
||||
missing from HEAD counts only when its sha is on origin/<default>; when
|
||||
it is not, one fetch decides. Every fetch (sync, pending, void, and add
|
||||
or update with --status merged) runs with GIT_TERMINAL_PROMPT=0 and is
|
||||
stopped after FETCH_SECONDS.
|
||||
|
||||
Output:
|
||||
add, update: `added hotfix/<slug>` or `updated hotfix/<slug>`.
|
||||
pending: `<verdict>\t<task>\t<status>\t<branch>\t<sha>\t<pr>` for each
|
||||
merged hotfix missing from HEAD (`missing (merge main)`, or
|
||||
`not on origin/<default>`) and each staged or pr one
|
||||
(`open, not merged`), then `hotfix: <n> missing, <m> open`.
|
||||
match: `<task>\t<status>\t<branch>\t<sha>\t<pr>\t<repository>` per match,
|
||||
with --repo prefixed `foreign\t` for another repository and
|
||||
`unknown\t` for an unresolvable one, or a `hotfix: no signature`
|
||||
line when nothing of the repository matches. A signature still
|
||||
searching at the SEARCH_SECONDS deadline is named on stderr and
|
||||
counts as no match.
|
||||
void: `void\t<task>\t<sha>\t<targets>` per hotfix that voids the run; a
|
||||
hotfix off origin/<default> voids nothing and is named on stderr.
|
||||
sync: `merged\t<ref>\t<new HEAD>` or `contained\t<ref>` per ref in merge
|
||||
order; `conflict\t<ref>\t<files>` on the first conflict.
|
||||
pending and void name every row whose repository is unknown (no origin
|
||||
URL and its git common dir gone) on stderr; such a row changes no exit
|
||||
code.
|
||||
|
||||
Exit codes:
|
||||
0 done; pending: none missing; match: a match of the repository; void:
|
||||
not void.
|
||||
1 pending: a merged hotfix is missing; match: no match.
|
||||
2 usage error, unknown task, dirty tracked tree, HEAD not on the task's
|
||||
branch, a held worktree, a chain row without workdir whose branch
|
||||
does not resolve, an unresolvable or diverged ref, a merge that
|
||||
failed without a conflict (aborted), a merged sha off
|
||||
origin/<default>; pending and void: a missing origin/HEAD or a
|
||||
failed fetch, unless void exits 3; match: the search worker did not
|
||||
start, did not get ready before the deadline or exited non-zero.
|
||||
3 void: a merged hotfix missing from HEAD turns a target green.
|
||||
4 sync: a merge conflicted and was aborted; earlier merges stay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import fcntl
|
||||
import importlib.util
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import NoReturn
|
||||
|
||||
SKILLS = Path(__file__).resolve().parents[2]
|
||||
RASCI = SKILLS / "kvb-org-rasci" / "scripts" / "rasci.py"
|
||||
GATE = SKILLS / "kvb-check-gate" / "scripts" / "gate.py"
|
||||
WATCH = SKILLS / "kvb-org-organize" / "scripts" / "watch.py"
|
||||
PREFIX = "hotfix/"
|
||||
STAGES = ("staged", "pr", "merged")
|
||||
REGISTER_STATUS = {"staged": "running", "pr": "running", "merged": "done"}
|
||||
SHA = re.compile(r"^[0-9a-f]{7,40}$")
|
||||
TARGET = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._%/+-]*$")
|
||||
UMBRELLA = "test"
|
||||
ORIGIN_HEAD = "refs/remotes/origin/HEAD"
|
||||
GIT = ("git", "-c", "maintenance.auto=false")
|
||||
FETCH = ("fetch", "--quiet", "--no-auto-maintenance", "origin")
|
||||
FETCH_SECONDS = 120
|
||||
URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://(?:[^@/]*@)?(?P<host>[^/:]*)(?::\d*)?(?P<path>/.*)?$")
|
||||
SCP = re.compile(r"^(?:[^@/:]+@)?(?P<host>[^/:]+):(?P<path>.*)$")
|
||||
CASELESS = ("github.com", "gitlab.com", "codeberg.org", "bitbucket.org")
|
||||
SEARCH_SECONDS = 3
|
||||
SEARCH = (
|
||||
"import json, re, sys\n"
|
||||
"data = json.loads(sys.stdin.buffer.read())\n"
|
||||
"print('ready', flush=True)\n"
|
||||
"for pattern in data['patterns']:\n"
|
||||
" print(int(re.search(pattern, data['line']) is not None), flush=True)\n"
|
||||
)
|
||||
CLASSES = {"DIGIT": r"\d", "SPACE": r"\s", "WORD": r"\w", "LINEBREAK": r"\n"}
|
||||
PROBES = frozenset(map(chr, range(128))) | {"é", "٠", " "}
|
||||
NESTED = "repeats a group that repeats itself, like (a+)+"
|
||||
OVERLAP = "repeats an alternation whose branches can start alike, like (a|ab|b)*"
|
||||
|
||||
|
||||
def fail(message: str) -> NoReturn:
|
||||
print(f"hotfix: {message}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def report(problems: list[str]) -> None:
|
||||
for problem in problems:
|
||||
print(f"hotfix: {problem}", file=sys.stderr)
|
||||
|
||||
|
||||
def module(name: str, path: Path) -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
loaded = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(loaded)
|
||||
return loaded
|
||||
|
||||
|
||||
def git(where: str, *args: str) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(
|
||||
[*GIT, *args], cwd=where, stdin=subprocess.DEVNULL, capture_output=True, text=True, check=False
|
||||
)
|
||||
except OSError as error:
|
||||
return subprocess.CompletedProcess([*GIT, *args], 128, "", str(error))
|
||||
|
||||
|
||||
def git_out(where: str, *args: str) -> str:
|
||||
result = git(where, *args)
|
||||
if result.returncode:
|
||||
fail(f"git {' '.join(args)} in {where}: {(result.stderr or result.stdout).strip()}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def fetch(where: str) -> subprocess.CompletedProcess:
|
||||
command = [*GIT, *FETCH]
|
||||
try:
|
||||
return subprocess.run(
|
||||
command, cwd=where, stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
||||
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, timeout=FETCH_SECONDS, check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(command, 124, "", f"stopped after {FETCH_SECONDS} s")
|
||||
except OSError as error:
|
||||
return subprocess.CompletedProcess(command, 128, "", str(error))
|
||||
|
||||
|
||||
def fetched(where: str) -> None:
|
||||
result = fetch(where)
|
||||
if result.returncode:
|
||||
fail(f"git fetch origin in {where}: {(result.stderr or result.stdout).strip()}")
|
||||
|
||||
|
||||
def ancestor(where: str, rev: str, of: str) -> bool:
|
||||
return git(where, "merge-base", "--is-ancestor", rev, of).returncode == 0
|
||||
|
||||
|
||||
def resolves(where: str, ref: str) -> bool:
|
||||
return git(where, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}").returncode == 0
|
||||
|
||||
|
||||
def task_of(rasci: ModuleType, slug: str) -> str:
|
||||
if not rasci.TASK.match(slug):
|
||||
fail(f"invalid slug {slug!r}; use lowercase letters, digits, '.', '_', '-' and '/'")
|
||||
return PREFIX + slug
|
||||
|
||||
|
||||
def worktree(value: str) -> str:
|
||||
path = os.path.realpath(value)
|
||||
if not os.path.isdir(path):
|
||||
fail(f"{value} is no directory")
|
||||
if identity(path) is None:
|
||||
fail(f"{value} is not a git repository")
|
||||
return path
|
||||
|
||||
|
||||
def clone(value: str) -> str:
|
||||
if not os.path.isabs(value):
|
||||
fail(f"--repo {value!r} is not an absolute path")
|
||||
return worktree(value)
|
||||
|
||||
|
||||
def normalized(url: str, top: str) -> str:
|
||||
"""Compare form of an origin URL.
|
||||
|
||||
Args:
|
||||
url: the origin URL with insteadOf rules applied.
|
||||
top: the directory a local relative path is relative to.
|
||||
"""
|
||||
url = url.strip()
|
||||
found = URL.match(url) or (None if url.startswith((".", "/")) else SCP.match(url))
|
||||
host, path = (found["host"].lower(), found["path"] or "") if found else ("", url)
|
||||
if host:
|
||||
path = path.rstrip("/").removesuffix(".git").rstrip("/").lstrip("/")
|
||||
return f"{host}/{path.lower() if host in CASELESS else path}"
|
||||
if not path:
|
||||
return ""
|
||||
return os.path.realpath(os.path.join(top, path)).removesuffix(".git").rstrip("/")
|
||||
|
||||
|
||||
def origin_url(where: str) -> str:
|
||||
if not git(where, "config", "--get", "remote.origin.url").stdout.strip():
|
||||
return ""
|
||||
url = git(where, "ls-remote", "--get-url", "origin").stdout
|
||||
top = git(where, "rev-parse", "--show-toplevel")
|
||||
return normalized(url, top.stdout.strip() if top.returncode == 0 else where)
|
||||
|
||||
|
||||
def identity(where: str) -> tuple[str, str] | None:
|
||||
if not where or not os.path.isdir(where):
|
||||
return None
|
||||
common = git(where, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
||||
if common.returncode:
|
||||
return None
|
||||
return os.path.realpath(common.stdout.strip()), origin_url(where)
|
||||
|
||||
|
||||
def same(one: tuple[str, str], other: tuple[str, str]) -> bool:
|
||||
if one[1] and other[1]:
|
||||
return one[1] == other[1]
|
||||
return one[0] == other[0]
|
||||
|
||||
|
||||
def recorded(row: dict) -> tuple[str, str] | None:
|
||||
common, origin = row.get("common_dir") or "", row.get("origin") or ""
|
||||
if not origin and not os.path.isdir(common):
|
||||
return None
|
||||
return common, origin
|
||||
|
||||
|
||||
def repository(row: dict) -> str:
|
||||
return row.get("origin") or row.get("common_dir") or "-"
|
||||
|
||||
|
||||
def unknown(task: str, row: dict) -> str:
|
||||
return (
|
||||
f"{task}: repository unknown (no origin URL, git dir {row.get('common_dir') or '-'} gone); record it with "
|
||||
f"`hotfix.py update {task.removeprefix(PREFIX)} --status {row['stage']} --sha {row['sha']} --repo <clone>`"
|
||||
)
|
||||
|
||||
|
||||
def hex_id(sha: str) -> str:
|
||||
if not SHA.match(sha):
|
||||
fail(f"--sha {sha!r} is no hex commit id of 7 to 40 characters")
|
||||
return sha
|
||||
|
||||
|
||||
def commit(where: str, sha: str) -> str:
|
||||
found = git(where, "rev-parse", "--verify", "--quiet", f"{hex_id(sha)}^{{commit}}")
|
||||
if found.returncode:
|
||||
fail(f"--sha {sha} is no commit in {where}")
|
||||
return found.stdout.strip()
|
||||
|
||||
|
||||
def default_branch(where: str, given: str | None) -> str:
|
||||
head = git(where, "symbolic-ref", "--quiet", ORIGIN_HEAD)
|
||||
if head.returncode == 0:
|
||||
name = head.stdout.strip().removeprefix("refs/remotes/origin/")
|
||||
if given is not None and given != name:
|
||||
fail(f"origin/HEAD names {name}, --default says {given}")
|
||||
return name
|
||||
if given is None:
|
||||
fail(f"{ORIGIN_HEAD} is missing in {where}; pass --default <branch> or run `git remote set-head origin --auto`")
|
||||
return branch_name(where, given, "--default")
|
||||
|
||||
|
||||
def landed(where: str, sha: str, given: str | None) -> str:
|
||||
hex_id(sha)
|
||||
fetched(where)
|
||||
name = default_branch(where, given)
|
||||
full = commit(where, sha)
|
||||
if not ancestor(where, full, f"refs/remotes/origin/{name}"):
|
||||
fail(f"--sha {sha} is not on origin/{name}; record the commit as it sits there, a squash or rebase merge "
|
||||
"changes it")
|
||||
return full
|
||||
|
||||
|
||||
def branch_name(where: str, name: str, what: str) -> str:
|
||||
if name.startswith("-") or git(where, "check-ref-format", f"refs/heads/{name}").returncode:
|
||||
fail(f"{what} {name!r} is no valid branch name")
|
||||
return name
|
||||
|
||||
|
||||
def children(value: object, subpattern: type) -> list:
|
||||
if isinstance(value, subpattern):
|
||||
return [value]
|
||||
if isinstance(value, (tuple, list)):
|
||||
return [sub for item in value for sub in children(item, subpattern)]
|
||||
return []
|
||||
|
||||
|
||||
def either(tests: list[Callable[[str], bool]]) -> Callable[[str], bool]:
|
||||
return lambda char: any(test(char) for test in tests)
|
||||
|
||||
|
||||
def risk(text: str) -> str | None:
|
||||
parser, c = getattr(re, "_parser", None), getattr(re, "_constants", None)
|
||||
if parser is None or c is None:
|
||||
print("hotfix: this Python hides re's parser; --signature is not checked for runaway backtracking",
|
||||
file=sys.stderr)
|
||||
return None
|
||||
loose = (c.MAX_REPEAT, c.MIN_REPEAT)
|
||||
repeats = (*loose, c.POSSESSIVE_REPEAT)
|
||||
barriers = (c.ATOMIC_GROUP, c.POSSESSIVE_REPEAT)
|
||||
probes = PROBES | set(text)
|
||||
|
||||
def parts(av: object) -> list:
|
||||
return children(av, parser.SubPattern)
|
||||
|
||||
def unbounded(op: object, av: object) -> bool:
|
||||
return op in loose and av[1] == c.MAXREPEAT
|
||||
|
||||
def inside(pattern: object, hit: Callable[[object, object], bool]) -> bool:
|
||||
return any(
|
||||
hit(op, av) or (op not in barriers and any(inside(part, hit) for part in parts(av)))
|
||||
for op, av in pattern.data
|
||||
)
|
||||
|
||||
def overlapping(op: object, av: object) -> bool:
|
||||
if op != c.BRANCH:
|
||||
return False
|
||||
heads = [start(branch) for branch in av[1]]
|
||||
return any(
|
||||
(one_empty and two_empty) or any(one(char) and two(char) for char in probes)
|
||||
for (one, one_empty), (two, two_empty) in itertools.combinations(heads, 2)
|
||||
)
|
||||
|
||||
def start(pattern: object) -> tuple[Callable[[str], bool], bool]:
|
||||
tests = []
|
||||
for op, av in pattern.data:
|
||||
test, empty = first(op, av)
|
||||
tests.append(test)
|
||||
if not empty:
|
||||
return either(tests), False
|
||||
return either(tests), True
|
||||
|
||||
def first(op: object, av: object) -> tuple[Callable[[str], bool], bool]:
|
||||
if op == c.LITERAL:
|
||||
return (lambda char: char == chr(av)), False
|
||||
if op == c.NOT_LITERAL:
|
||||
return (lambda char: char != chr(av)), False
|
||||
if op == c.ANY:
|
||||
return (lambda char: True), False
|
||||
if op == c.IN:
|
||||
return member(av), False
|
||||
if op in (c.AT, c.ASSERT, c.ASSERT_NOT):
|
||||
return (lambda char: False), True
|
||||
if op in repeats:
|
||||
test, empty = start(av[2])
|
||||
return test, empty or av[0] == 0
|
||||
if op in (c.SUBPATTERN, c.ATOMIC_GROUP, c.BRANCH):
|
||||
heads = [start(part) for part in parts(av)]
|
||||
return either([test for test, _ in heads]), any(empty for _, empty in heads)
|
||||
return (lambda char: True), True
|
||||
|
||||
def member(items: list) -> Callable[[str], bool]:
|
||||
negated = bool(items) and items[0][0] == c.NEGATE
|
||||
kept = [item for item in items if item[0] != c.NEGATE]
|
||||
return lambda char: any(holds(op, av, char) for op, av in kept) != negated
|
||||
|
||||
def holds(op: object, av: object, char: str) -> bool:
|
||||
if op == c.LITERAL:
|
||||
return char == chr(av)
|
||||
if op == c.RANGE:
|
||||
return av[0] <= ord(char) <= av[1]
|
||||
if op == c.CATEGORY:
|
||||
name = av.name.removeprefix("CATEGORY_").removeprefix("LOC_").removeprefix("UNI_")
|
||||
klass = CLASSES.get(name.removeprefix("NOT_"))
|
||||
return klass is None or (re.fullmatch(klass, char) is not None) != name.startswith("NOT_")
|
||||
return True
|
||||
|
||||
def scan(pattern: object) -> str | None:
|
||||
for op, av in pattern.data:
|
||||
if unbounded(op, av) and inside(av[2], unbounded):
|
||||
return NESTED
|
||||
if unbounded(op, av) and inside(av[2], overlapping):
|
||||
return OVERLAP
|
||||
for part in parts(av):
|
||||
found = scan(part)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
return scan(parser.parse(text))
|
||||
|
||||
|
||||
def signature(text: str) -> str:
|
||||
if not text.strip():
|
||||
fail("--signature must not be empty")
|
||||
try:
|
||||
re.compile(text)
|
||||
except re.error as error:
|
||||
fail(f"--signature {text!r}: {error}")
|
||||
found = risk(text)
|
||||
if found is not None:
|
||||
fail(f"--signature {text!r} {found}, which backtracks without end on a near miss; match the stable part "
|
||||
"instead")
|
||||
return text
|
||||
|
||||
|
||||
def searched(patterns: list[str], line: str) -> list[bool]:
|
||||
"""Search the line with every pattern in order in one worker until SEARCH_SECONDS pass.
|
||||
|
||||
Args:
|
||||
patterns: signatures that compile.
|
||||
line: the log line.
|
||||
|
||||
Returns:
|
||||
one verdict per pattern; fewer when the deadline passed, the next pattern still searching.
|
||||
"""
|
||||
payload = json.dumps({"line": line, "patterns": patterns}).encode("ascii")
|
||||
late = False
|
||||
try:
|
||||
with subprocess.Popen(
|
||||
[sys.executable, "-I", "-c", SEARCH], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
) as worker:
|
||||
try:
|
||||
out, err = worker.communicate(payload, timeout=SEARCH_SECONDS)
|
||||
except subprocess.TimeoutExpired:
|
||||
late = True
|
||||
worker.kill()
|
||||
out, err = worker.communicate()
|
||||
except OSError as error:
|
||||
fail(f"cannot start the signature search worker {sys.executable}: {error.strerror or error}")
|
||||
*lines, tail = out.decode("ascii", "replace").split("\n")
|
||||
verdicts = lines[1:]
|
||||
if late and lines[:1] != ["ready"]:
|
||||
fail(f"the signature search worker did not get ready within {SEARCH_SECONDS} s")
|
||||
complete = late or (worker.returncode == 0 and not tail and len(verdicts) == len(patterns))
|
||||
if lines[:1] != ["ready"] or not complete or len(verdicts) > len(patterns) or set(verdicts) - {"0", "1"}:
|
||||
reason = err.decode("utf-8", "replace").strip().splitlines()[-1:] or ["no message"]
|
||||
fail(f"the signature search worker exited {worker.returncode}: {reason[0]}")
|
||||
return [verdict == "1" for verdict in verdicts]
|
||||
|
||||
|
||||
def make_targets(values: list[str] | None) -> list[str]:
|
||||
for value in values or []:
|
||||
if not TARGET.match(value):
|
||||
fail(f"--target {value!r} is no make target name")
|
||||
return list(dict.fromkeys(values or []))
|
||||
|
||||
|
||||
def turns_green(greens: list[str], wanted: set[str]) -> bool:
|
||||
if not greens or wanted & set(greens):
|
||||
return True
|
||||
return UMBRELLA in wanted and any(green.startswith(f"{UMBRELLA}-") for green in greens)
|
||||
|
||||
|
||||
def number(value: int | None) -> int | None:
|
||||
if value is not None and value < 1:
|
||||
fail("--pr must be 1 or higher")
|
||||
return value
|
||||
|
||||
|
||||
def hotfixes(rasci: ModuleType) -> list[tuple[str, dict]]:
|
||||
return [(t, row) for t, row in sorted(rasci.load().items()) if t.startswith(PREFIX) and "stage" in row]
|
||||
|
||||
|
||||
def of_repository(rasci: ModuleType, repo: str) -> tuple[list[tuple[str, dict]], list[str]]:
|
||||
here = identity(repo)
|
||||
rows, unknowns = [], []
|
||||
for task, row in hotfixes(rasci):
|
||||
theirs = recorded(row)
|
||||
if theirs is None:
|
||||
unknowns.append(unknown(task, row))
|
||||
elif same(theirs, here):
|
||||
rows.append((task, row))
|
||||
return rows, unknowns
|
||||
|
||||
|
||||
def for_targets(rows: list[tuple[str, dict]], targets: list[str] | None) -> list[tuple[str, dict]]:
|
||||
if targets is None:
|
||||
return rows
|
||||
wanted = set(make_targets(targets))
|
||||
return [(task, row) for task, row in rows if turns_green(row.get("targets") or [], wanted)]
|
||||
|
||||
|
||||
def missing(repo: str, rows: list[tuple[str, dict]], problems: list[str]) -> tuple[list, list, str]:
|
||||
absent = [(t, row) for t, row in rows if row["stage"] == "merged" and not ancestor(repo, row["sha"], "HEAD")]
|
||||
if not absent:
|
||||
return [], [], ""
|
||||
head = git(repo, "symbolic-ref", "--quiet", ORIGIN_HEAD)
|
||||
if head.returncode:
|
||||
problems.append(f"{ORIGIN_HEAD} is missing in {repo}, so {', '.join(t for t, _ in absent)} stay unchecked; "
|
||||
"run `git remote set-head origin --auto`")
|
||||
return [], [], ""
|
||||
ref = head.stdout.strip()
|
||||
on = {t for t, row in absent if ancestor(repo, row["sha"], ref)}
|
||||
if len(on) < len(absent):
|
||||
result = fetch(repo)
|
||||
if result.returncode:
|
||||
unchecked = ", ".join(t for t, _ in absent if t not in on)
|
||||
problems.append(f"git fetch origin in {repo} failed, so {unchecked} stay unchecked: {result.stderr.strip()}")
|
||||
return [(t, row) for t, row in absent if t in on], [], ""
|
||||
on = {t for t, row in absent if ancestor(repo, row["sha"], ref)}
|
||||
shown = ref.removeprefix("refs/remotes/")
|
||||
return [(t, row) for t, row in absent if t in on], [(t, row) for t, row in absent if t not in on], shown
|
||||
|
||||
|
||||
def pr_cell(row: dict) -> str:
|
||||
return f"#{row['pr']}" if row.get("pr") else "-"
|
||||
|
||||
|
||||
def cmd_add(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
task = task_of(rasci, args.slug)
|
||||
if task in rasci.load():
|
||||
fail(f"{task} is already in the register; move it on with update")
|
||||
repo = clone(args.repo)
|
||||
common, origin = identity(repo)
|
||||
entry = {
|
||||
"common_dir": common,
|
||||
"origin": origin,
|
||||
"branch": branch_name(repo, args.branch, "--branch"),
|
||||
"signature": signature(args.signature),
|
||||
"targets": make_targets(args.target),
|
||||
"stage": args.status,
|
||||
}
|
||||
pr = number(args.pr)
|
||||
if pr is not None:
|
||||
entry["pr"] = pr
|
||||
entry["sha"] = landed(repo, args.sha, args.default) if args.status == "merged" else commit(repo, args.sha)
|
||||
council = module("council", rasci.COUNCIL)
|
||||
sid, _ = council.caller(args.session)
|
||||
entry["roles"] = {council.live_sessions().get(sid) or sid: "RA"}
|
||||
rasci.apply_status(entry, REGISTER_STATUS[args.status])
|
||||
with rasci.database() as tasks:
|
||||
if task in tasks:
|
||||
fail(f"{task} is already in the register; move it on with update")
|
||||
tasks[task] = entry
|
||||
print(f"added {task}")
|
||||
return 0
|
||||
|
||||
|
||||
def located(task: str, entry: dict, given: str | None) -> tuple[str, dict]:
|
||||
if given is None:
|
||||
common = entry.get("common_dir") or ""
|
||||
if not os.path.isdir(common):
|
||||
fail(f"{task}: its git dir {common or '-'} is gone; pass --repo <a clone of {repository(entry)}>")
|
||||
return common, {}
|
||||
repo = clone(given)
|
||||
here = identity(repo)
|
||||
theirs = recorded(entry)
|
||||
if theirs is not None and not same(theirs, here):
|
||||
fail(f"--repo {given} is another repository than {task}'s {repository(entry)}")
|
||||
return repo, {"common_dir": here[0], "origin": here[1]}
|
||||
|
||||
|
||||
def cmd_update(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
task = task_of(rasci, args.slug)
|
||||
pr = number(args.pr)
|
||||
if args.status == "merged" and args.sha is None:
|
||||
fail("--status merged needs --sha, the commit as it sits on the default branch")
|
||||
entry = rasci.load().get(task)
|
||||
if entry is None or "stage" not in entry:
|
||||
fail(f"{task}: no hotfix in the register; record it with add")
|
||||
fields: dict = {}
|
||||
if args.repo is not None or args.sha is not None:
|
||||
where, fields = located(task, entry, args.repo)
|
||||
if args.sha is not None:
|
||||
fields["sha"] = landed(where, args.sha, args.default) if args.status == "merged" else commit(where, args.sha)
|
||||
if pr is not None:
|
||||
fields["pr"] = pr
|
||||
with rasci.database() as tasks:
|
||||
entry = tasks.get(task)
|
||||
if entry is None or "stage" not in entry:
|
||||
fail(f"{task}: no hotfix in the register; record it with add")
|
||||
entry.update(fields)
|
||||
entry["stage"] = args.status
|
||||
rasci.apply_status(entry, REGISTER_STATUS[args.status])
|
||||
print(f"updated {task}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_pending(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
repo = worktree(args.repo)
|
||||
rows, unknowns = of_repository(rasci, repo)
|
||||
rows = for_targets(rows, args.target)
|
||||
problems: list[str] = []
|
||||
on, off, shown = missing(repo, rows, problems)
|
||||
verdicts = {t: "missing (merge main)" for t, _ in on} | {t: f"not on {shown}" for t, _ in off}
|
||||
opened = 0
|
||||
for task, row in rows:
|
||||
if row["stage"] != "merged":
|
||||
verdict = "open, not merged"
|
||||
opened += 1
|
||||
elif task in verdicts:
|
||||
verdict = verdicts[task]
|
||||
else:
|
||||
continue
|
||||
print("\t".join((verdict, task, row["stage"], row["branch"], row["sha"], pr_cell(row))))
|
||||
report([*unknowns, *problems])
|
||||
print(f"hotfix: {len(on)} missing, {opened} open")
|
||||
if problems:
|
||||
return 2
|
||||
return 1 if on else 0
|
||||
|
||||
|
||||
def cmd_match(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
here = identity(worktree(args.repo)) if args.repo is not None else None
|
||||
rows = []
|
||||
for task, row in hotfixes(rasci):
|
||||
try:
|
||||
re.compile(row["signature"])
|
||||
except re.error as error:
|
||||
print(f"hotfix: {task}: signature does not compile ({error}), skipped", file=sys.stderr)
|
||||
continue
|
||||
rows.append((task, row))
|
||||
hits = []
|
||||
while rows:
|
||||
verdicts = searched([row["signature"] for _, row in rows], args.line)
|
||||
hits += [entry for entry, hit in zip(rows[:len(verdicts)], verdicts, strict=True) if hit]
|
||||
if len(verdicts) == len(rows):
|
||||
break
|
||||
task, row = rows[len(verdicts)]
|
||||
print(f"hotfix: {task}: signature {row['signature']!r} still searched this line at the {SEARCH_SECONDS} s "
|
||||
"deadline, skipped; its A must narrow it", file=sys.stderr)
|
||||
rows = rows[len(verdicts) + 1:]
|
||||
found = 0
|
||||
for task, row in hits:
|
||||
cells = [task, row["stage"], row["branch"], row["sha"], pr_cell(row), repository(row)]
|
||||
theirs = recorded(row)
|
||||
if here is not None and theirs is None:
|
||||
cells.insert(0, "unknown")
|
||||
elif here is not None and not same(theirs, here):
|
||||
cells.insert(0, "foreign")
|
||||
else:
|
||||
found += 1
|
||||
print("\t".join(cells))
|
||||
if not found:
|
||||
print("hotfix: no signature matches" if here is None else "hotfix: no signature of this repository matches")
|
||||
return 0 if found else 1
|
||||
|
||||
|
||||
def cmd_void(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
repo = worktree(args.repo)
|
||||
rows, unknowns = of_repository(rasci, repo)
|
||||
rows = for_targets(rows, args.target)
|
||||
problems: list[str] = []
|
||||
on, off, shown = missing(repo, rows, problems)
|
||||
for task, row in off:
|
||||
print(f"hotfix: {task}: {row['sha']} is not on {shown} (a squash or rebase merge?), so it voids nothing; "
|
||||
"its A must record the commit as it sits there", file=sys.stderr)
|
||||
for task, row in on:
|
||||
print("\t".join(("void", task, row["sha"], ",".join(row.get("targets") or []) or "all targets")))
|
||||
report([*unknowns, *problems])
|
||||
if on:
|
||||
return 3
|
||||
return 2 if problems else 0
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def locked(repo: str, command: list[str]) -> Iterator[None]:
|
||||
"""Hold gate.py's sync lock and worktree lock of the worktree.
|
||||
|
||||
Args:
|
||||
repo: the worktree.
|
||||
command: sync's command line, recorded in the sync lock.
|
||||
"""
|
||||
if not GATE.is_file():
|
||||
yield
|
||||
return
|
||||
gate = module("gate", GATE)
|
||||
git_dir = Path(git_out(repo, "rev-parse", "--absolute-git-dir").strip())
|
||||
with (
|
||||
open(git_dir / gate.SYNC_LOCK, "a", encoding="utf-8") as sync,
|
||||
open(git_dir / gate.RUN_LOCK, "a", encoding="utf-8") as run,
|
||||
):
|
||||
fcntl.flock(sync, fcntl.LOCK_EX)
|
||||
gate.claim(sync, command)
|
||||
try:
|
||||
fcntl.flock(run, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
fail(f"a gate.py run or hold holds {repo} ({gate.holder(git_dir / gate.RUN_LOCK)}); sync once it ends")
|
||||
yield
|
||||
|
||||
|
||||
def watched(repo: str) -> None:
|
||||
if WATCH.is_file():
|
||||
watch = module("watch", WATCH)
|
||||
config = watch.load_config()
|
||||
if config is not None and watch.busy(repo, config["busy_processes"]):
|
||||
fail(f"a busy process (watch.py busy_processes) runs in {repo}, a gate or deploy; sync once it ends")
|
||||
|
||||
|
||||
def chain(repo: str, tasks: dict[str, dict], task: str, default: str, ended: tuple[str, ...]) -> list[str]:
|
||||
here = identity(repo)
|
||||
places: dict[str, tuple[str, str] | None] = {}
|
||||
found: list[str] = []
|
||||
base = tasks[task].get("base")
|
||||
while base and base != default:
|
||||
if base in found:
|
||||
fail(f"base chain of {task} loops: {' <- '.join([*found, base])}")
|
||||
found.append(branch_name(repo, base, "base"))
|
||||
upstream = {}
|
||||
for name, row in sorted(tasks.items()):
|
||||
if row.get("branch") != base or not row.get("base") or row.get("status") in ended:
|
||||
continue
|
||||
workdir = row.get("workdir") or ""
|
||||
if not workdir:
|
||||
if not (resolves(repo, f"refs/heads/{base}") or resolves(repo, f"refs/remotes/origin/{base}")):
|
||||
fail(f"{name} records no workdir and its branch {base} resolves neither as {base} nor as "
|
||||
f"origin/{base} in {repo}; record it with `rasci.py set {name} --workdir <dir>`")
|
||||
upstream[name] = row["base"]
|
||||
continue
|
||||
if workdir not in places:
|
||||
places[workdir] = identity(workdir)
|
||||
if places[workdir] is None:
|
||||
print(f"hotfix: {name}: workdir {workdir} is no git repository, its base is not followed",
|
||||
file=sys.stderr)
|
||||
elif same(places[workdir], here):
|
||||
upstream[name] = row["base"]
|
||||
if len(set(upstream.values())) > 1:
|
||||
named = ", ".join(f"{t}={b}" for t, b in sorted(upstream.items()))
|
||||
fail(f"branch {base} has several bases in the register: {named}")
|
||||
base = next(iter(upstream.values()), None)
|
||||
return found
|
||||
|
||||
|
||||
def base_ref(repo: str, base: str) -> tuple[str, str]:
|
||||
local, remote = f"refs/heads/{base}", f"refs/remotes/origin/{base}"
|
||||
if not resolves(repo, remote):
|
||||
if not resolves(repo, local):
|
||||
fail(f"{base} does not resolve in {repo}")
|
||||
return base, local
|
||||
if not resolves(repo, local) or ancestor(repo, local, remote):
|
||||
return f"origin/{base}", remote
|
||||
if ancestor(repo, remote, local):
|
||||
return base, local
|
||||
fail(f"{base} and origin/{base} have diverged in {repo}; merge one into the other first")
|
||||
|
||||
|
||||
def default_ref(repo: str, given: str | None) -> tuple[str, tuple[str, str]]:
|
||||
if git(repo, "config", "--get", "remote.origin.url").stdout.strip():
|
||||
default = default_branch(repo, given)
|
||||
fetched(repo)
|
||||
return default, (f"origin/{default}", f"refs/remotes/origin/{default}")
|
||||
if given is None:
|
||||
fail(f"{repo} has no origin remote; pass --default <branch>, its local default branch")
|
||||
default = branch_name(repo, given, "--default")
|
||||
return default, (default, f"refs/heads/{default}")
|
||||
|
||||
|
||||
def cmd_sync(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
repo = worktree(args.repo)
|
||||
tasks = rasci.load()
|
||||
if args.task not in tasks:
|
||||
fail(f"{args.task}: not in the register")
|
||||
branch = tasks[args.task].get("branch")
|
||||
if not branch:
|
||||
fail(f"{args.task} records no branch; set it with `rasci.py set {args.task} --branch <branch>`")
|
||||
head = git(repo, "symbolic-ref", "--quiet", "HEAD").stdout.strip()
|
||||
if head != f"refs/heads/{branch}":
|
||||
fail(f"{repo} has {head.removeprefix('refs/heads/') or 'a detached HEAD'} checked out, "
|
||||
f"{args.task} works on {branch}")
|
||||
default, first = default_ref(repo, args.default)
|
||||
with locked(repo, ["hotfix.py", "sync", "--repo", repo, "--task", args.task]):
|
||||
watched(repo)
|
||||
dirty = [line[3:] for line in git_out(repo, "status", "--porcelain=v1", "--untracked-files=no").splitlines()]
|
||||
if dirty:
|
||||
fail(f"tracked files are dirty, commit them first: {', '.join(dirty)}")
|
||||
if not resolves(repo, first[1]):
|
||||
fail(f"{first[0]} does not resolve in {repo}")
|
||||
bases = chain(repo, tasks, args.task, default, rasci.ENDED)
|
||||
for shown, ref in [first, *(base_ref(repo, base) for base in bases)]:
|
||||
if ancestor(repo, ref, "HEAD"):
|
||||
print(f"contained\t{shown}")
|
||||
continue
|
||||
result = git(repo, "merge", "--no-verify", "--no-edit", ref)
|
||||
if result.returncode == 0:
|
||||
print(f"merged\t{shown}\t{git_out(repo, 'rev-parse', 'HEAD').strip()}")
|
||||
continue
|
||||
files = git(repo, "diff", "--name-only", "--diff-filter=U").stdout.splitlines()
|
||||
if git(repo, "rev-parse", "--verify", "--quiet", "MERGE_HEAD").returncode == 0:
|
||||
git_out(repo, "merge", "--abort")
|
||||
if not files:
|
||||
fail(f"git merge {shown} failed and was aborted: {(result.stderr or result.stdout).strip()}")
|
||||
print(f"conflict\t{shown}\t{','.join(files)}")
|
||||
print(f"hotfix: merging {shown} conflicts in {', '.join(files)}; merge aborted", file=sys.stderr)
|
||||
return 4
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
p = sub.add_parser("add")
|
||||
p.add_argument("slug")
|
||||
for flag in ("--repo", "--branch", "--sha", "--signature"):
|
||||
p.add_argument(flag, required=True)
|
||||
p.add_argument("--status", choices=STAGES, required=True)
|
||||
p.add_argument("--target", action="append")
|
||||
p.add_argument("--pr", type=int)
|
||||
p.add_argument("--default")
|
||||
p.add_argument("--session")
|
||||
p.set_defaults(func=cmd_add)
|
||||
p = sub.add_parser("update")
|
||||
p.add_argument("slug")
|
||||
p.add_argument("--status", choices=STAGES, required=True)
|
||||
p.add_argument("--sha")
|
||||
p.add_argument("--pr", type=int)
|
||||
p.add_argument("--repo")
|
||||
p.add_argument("--default")
|
||||
p.set_defaults(func=cmd_update)
|
||||
p = sub.add_parser("pending")
|
||||
p.add_argument("--repo", required=True)
|
||||
p.add_argument("--target", action="append")
|
||||
p.set_defaults(func=cmd_pending)
|
||||
p = sub.add_parser("match")
|
||||
p.add_argument("--line", required=True)
|
||||
p.add_argument("--repo")
|
||||
p.set_defaults(func=cmd_match)
|
||||
p = sub.add_parser("void")
|
||||
p.add_argument("--repo", required=True)
|
||||
p.add_argument("--target", action="append", required=True)
|
||||
p.set_defaults(func=cmd_void)
|
||||
p = sub.add_parser("sync")
|
||||
p.add_argument("--repo", required=True)
|
||||
p.add_argument("--task", required=True)
|
||||
p.add_argument("--default")
|
||||
p.set_defaults(func=cmd_sync)
|
||||
args = parser.parse_args()
|
||||
return args.func(module("rasci", RASCI), args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user