Compare commits
6 Commits
9c26afd8d2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| be73235c01 | |||
| 67b133a115 | |||
| 259bdd93ca | |||
| e6f18b5a3b | |||
| 3a0d8db656 | |||
| f9777ccbd2 |
@@ -15,7 +15,7 @@ fetch it — which was measured to be the difference between one step and three.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `with-nixpkgs.sh` | Runs a command with one nixpkgs package on PATH, pinned to the *consuming* repo's `flake.lock`. Avoids `nix shell nixpkgs#x`, which re-resolves the registry and refetches a channel tarball whenever the branch moves. |
|
| `with-nixpkgs.sh` | Runs a command with one nixpkgs package on PATH, pinned to the *consuming* repo's `flake.lock`. Avoids `nix shell nixpkgs#x`, which re-resolves the registry and refetches a channel tarball whenever the branch moves. |
|
||||||
| `report_job_log.py` | Posts the tail of a build log as a PR comment. Exists because `actions/jobs/{id}/logs` returns 500 for every id on Gitea 1.25.2, so a red job otherwise says only that it failed. |
|
| `report_job_log.py` | Posts the tail of a build log as a PR comment. Exists because `actions/jobs/{id}/logs` returns 500 for every id on Gitea 1.25.2, so a red job otherwise says only that it failed. |
|
||||||
| `sync_blocked_label.py` | Keeps `Status/Blocked` in step with Gitea's dependency graph. |
|
| `sync_blocked_label.py` | Keeps `Status/Blocked` in step with Gitea's dependency graph. Resolves the label from the repo *or the organisation*, and never touches an issue marked `Status/On Hold` or `Status/Abandoned`. |
|
||||||
|
|
||||||
All three are standard library / plain bash only. They are *run*, not built, so
|
All three are standard library / plain bash only. They are *run*, not built, so
|
||||||
this repo has no flake.
|
this repo has no flake.
|
||||||
|
|||||||
BIN
__pycache__/report_job_log.cpython-313.pyc
Normal file
BIN
__pycache__/report_job_log.cpython-313.pyc
Normal file
Binary file not shown.
@@ -9,6 +9,10 @@ can be read, next to the change that caused it.
|
|||||||
Standard library only, like `sync_blocked_label.py`, so it needs nothing but an
|
Standard library only, like `sync_blocked_label.py`, so it needs nothing but an
|
||||||
interpreter.
|
interpreter.
|
||||||
|
|
||||||
|
Lives in `weblib/weblib-ci` and is used by the other repos from there, so the
|
||||||
|
comment it posts must not name a path inside the repo it is reporting on --
|
||||||
|
there is no copy there to find.
|
||||||
|
|
||||||
Usage, from a workflow step guarded by `if: failure()`:
|
Usage, from a workflow step guarded by `if: failure()`:
|
||||||
|
|
||||||
report_job_log.py /tmp/build.log
|
report_job_log.py /tmp/build.log
|
||||||
@@ -143,7 +147,8 @@ def main(argv=None):
|
|||||||
sha = (os.environ.get("GITHUB_SHA") or "")[:8]
|
sha = (os.environ.get("GITHUB_SHA") or "")[:8]
|
||||||
body = (f"**`{args.job}` failed**{f' on `{sha}`' if sha else ''}.\n\n"
|
body = (f"**`{args.job}` failed**{f' on `{sha}`' if sha else ''}.\n\n"
|
||||||
"Job logs return 500 on this Gitea, so here is the tail of the "
|
"Job logs return 500 on this Gitea, so here is the tail of the "
|
||||||
"build output, posted by `tools/report_job_log.py`.\n\n"
|
"build output, posted by [`report_job_log.py`]"
|
||||||
|
"(https://git.chaosbit.de/weblib/weblib-ci).\n\n"
|
||||||
f"{text}\n")
|
f"{text}\n")
|
||||||
|
|
||||||
# A failure to report a failure must not itself be silent, but it also must
|
# A failure to report a failure must not itself be silent, but it also must
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ import urllib.request
|
|||||||
DEFAULT_LABEL = "Status/Blocked"
|
DEFAULT_LABEL = "Status/Blocked"
|
||||||
DEFAULT_HOST = "https://git.chaosbit.de"
|
DEFAULT_HOST = "https://git.chaosbit.de"
|
||||||
|
|
||||||
|
#: Statuses that mean a human has decided something about this issue which
|
||||||
|
#: outranks the dependency graph. bit, 2026-09-07: *"the reconciler must not
|
||||||
|
#: touch issues that are already on hold or abandoned"*.
|
||||||
|
#:
|
||||||
|
#: "Not touch" is literal - neither add nor remove. Adding would be actively
|
||||||
|
#: destructive once `Status/*` is exclusive again, because the add would
|
||||||
|
#: *replace* the human's label rather than sit beside it, and `Status/On Hold`
|
||||||
|
#: is precisely what makes the backlog sweep skip an issue. A parked issue
|
||||||
|
#: would silently become an available one, every fifteen minutes, with nothing
|
||||||
|
#: in the log to say so.
|
||||||
|
HANDS_OFF = ("Status/On Hold", "Status/Abandoned")
|
||||||
|
|
||||||
|
|
||||||
class Forge:
|
class Forge:
|
||||||
def __init__(self, base, token):
|
def __init__(self, base, token):
|
||||||
@@ -75,15 +87,29 @@ class Forge:
|
|||||||
|
|
||||||
|
|
||||||
def label_id(forge, repo, name):
|
def label_id(forge, repo, name):
|
||||||
"""The label's id, or None if this repo has no such label.
|
"""The label's id, or None if neither the repo nor its org has one.
|
||||||
|
|
||||||
|
Repo first, then the organisation. Labels moved to the org on 2026-09-07
|
||||||
|
(weblib-archive#63) and `repos/<r>/labels` now returns `[]` in all five
|
||||||
|
repos, which made this return None everywhere and turned the whole script
|
||||||
|
into a silent no-op - every run printed "skipped" and reported success.
|
||||||
|
Checking both means it does not care how a given instance is arranged.
|
||||||
|
|
||||||
Returning None rather than exiting matters when several repos are passed:
|
Returning None rather than exiting matters when several repos are passed:
|
||||||
aborting on the third would leave the first two already modified, which is
|
aborting on the third would leave the first two already modified, which is
|
||||||
a worse state than doing nothing. weblib-viewer has no labels at all.
|
a worse state than doing nothing.
|
||||||
"""
|
"""
|
||||||
for label in forge.get(f"repos/{repo}/labels?limit=100"):
|
owner = repo.split("/")[0]
|
||||||
if label["name"] == name:
|
for path in (f"repos/{repo}/labels?limit=100",
|
||||||
return label["id"]
|
f"orgs/{owner}/labels?limit=100"):
|
||||||
|
try:
|
||||||
|
labels = forge.get(path) or []
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
# A user-owned repo has no org endpoint; not an error worth dying on.
|
||||||
|
continue
|
||||||
|
for label in labels:
|
||||||
|
if label["name"] == name:
|
||||||
|
return label["id"]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -110,6 +136,15 @@ def reconcile(forge, repo, label_name, dry_run):
|
|||||||
changed = []
|
changed = []
|
||||||
for item in open_items(forge, repo):
|
for item in open_items(forge, repo):
|
||||||
number = item["number"]
|
number = item["number"]
|
||||||
|
names = {l["name"] for l in item.get("labels") or []}
|
||||||
|
# A human has already ruled on this one. Leave it entirely alone -
|
||||||
|
# neither add nor remove - rather than letting the dependency graph
|
||||||
|
# overwrite a deliberate decision. See HANDS_OFF.
|
||||||
|
held = names.intersection(HANDS_OFF)
|
||||||
|
if held:
|
||||||
|
print(f" hands off {repo}#{number} ({', '.join(sorted(held))})",
|
||||||
|
flush=True)
|
||||||
|
continue
|
||||||
deps = forge.get(f"repos/{repo}/issues/{number}/dependencies") or []
|
deps = forge.get(f"repos/{repo}/issues/{number}/dependencies") or []
|
||||||
if not deps:
|
if not deps:
|
||||||
# No edges, no opinion. See the docstring.
|
# No edges, no opinion. See the docstring.
|
||||||
|
|||||||
Reference in New Issue
Block a user