Files
weblib-ci/report_job_log.py
T
claudeandbit c2947db08a
report_job_log live-smoke-test passed: live smoke test log line REGRESSION-CHECK-LIVE-SMOKE
Give a push-triggered job somewhere to report a failure
report_job_log.py only ever commented on an open PR, so a push to
development/main -- the shape of a publish pipeline, which has no
pull_request trigger -- had nowhere to post: the branch pushed to is the
merge's target, not the already-closed PR that produced it. Combined with
job logs 500ing on this Gitea, that made a push-triggered failure completely
invisible (weblib-viewer run 1769, diagnosed only by hardcoding --pr on a
throwaway commit).

A GitHub-style commit-comment endpoint, the first thing tried, does not
exist on this Gitea: 404, and absent from /swagger.v1.json entirely. What
does exist and was verified live: GET commits/{sha}/pull resolves the PR
that produced a given commit, open or closed, across both merge styles this
org uses -- so the closed PR the push actually came from can still be found
and commented on as before. When even that finds nothing (a commit that was
never part of any PR), fall back to a commit status, since that endpoint is
already proven to work here.

No CLI changes; existing callers are unaffected.

Closes #14

Co-authored-by: bit <bit@das-labor.org>
2026-09-10 10:46:23 +00:00

353 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""Post the tail of a build log as a pull request comment.
Gitea 1.25.2 returns 500 from `actions/jobs/{id}/logs` for **every** id, so a
red job reports `Failing after 48s` and nothing else. Every hypothesis about
why then costs a push, a wait, and a one-bit answer. This puts the log where it
can be read, next to the change that caused it.
Standard library only, like `sync_blocked_label.py`, so it needs nothing but an
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()`:
report_job_log.py /tmp/build.log
The header says *failed* by default, which is what that guard means. A step
guarded by `if: always()` -- a probe, or a job whose output is the point rather
than its exit code -- has to say so, or the comment reports a failure that did
not happen:
report_job_log.py /tmp/build.log --status "${{ job.status }}"
Everything else comes from the Actions environment: `GITHUB_REPOSITORY`,
`GITHUB_EVENT_PATH` (for the PR number), `GITEA_TOKEN`/`GITHUB_TOKEN`,
`GITHUB_SERVER_URL`, and `JOB_STATUS` if `--status` is not passed.
A `push` event has no open PR to comment on -- the common case is that a PR
just merged into `development`/`main` and closed, so `GITHUB_HEAD_REF` is the
target branch, not the one that was merged (weblib-ci#14). Before giving up,
this asks Gitea which PR produced `GITHUB_SHA`
(`GET .../commits/{sha}/pull`, which resolves a closed PR from its merge
commit -- verified against both merge styles this org uses). If that also
comes up empty -- a commit that was never part of any PR -- the log itself has
nowhere to go, so a commit status is posted instead: shorter, but still
readable next to the commit, unlike silence.
Remove this once the instance is updated and logs can be read directly.
"""
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
DEFAULT_HOST = "https://git.chaosbit.de"
# Gitea renders a comment body wholesale; a full nix log is megabytes and the
# failure is always at the end.
DEFAULT_TAIL = 12000
# nix colours its output, and the raw escapes are noise in Markdown.
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
# A commit status is UI chrome (a line in a list), not a page -- measured
# against this Gitea: it accepted a 3000-char description unchanged, so
# nothing here truncates it server-side, but that does not make a wall of log
# a good idea to put there. Keep it to a snippet.
STATUS_TAIL = 500
# The header used to hardcode "failed". Every caller guards the step with
# `if: failure()`, so that was true by construction -- until an `if: always()`
# probe in weblib-viewer#10 posted a failure report for a job that had passed.
# The default stays `failed` so those guarded callers are unchanged.
DEFAULT_STATUS = "failed"
# `${{ job.status }}` yields success/failure/cancelled/skipped; a human writing
# the flag by hand reaches for passed/failed. Accept both spellings so a call
# site can pass the expression straight through.
STATUS_PHRASES = {
"failed": "failed",
"failure": "failed",
"passed": "passed",
"success": "passed",
"succeeded": "passed",
"cancelled": "was cancelled",
"canceled": "was cancelled",
"skipped": "was skipped",
}
# Gitea's status `state` enum is not the same vocabulary as the header
# phrases above (`pending, success, error, failure, warning, skipped`).
# A phrase this table has not heard of -- the "finished with status `X`"
# fallback -- maps to `error`: the honest reading is "something unusual
# happened", not a guess at pass or fail.
STATUS_STATE = {
"failed": "failure",
"passed": "success",
"was cancelled": "warning",
"was skipped": "skipped",
}
def status_phrase(status):
"""The verb for the comment header.
An unrecognised status is reported verbatim rather than rejected. This
script exists because a red job says nothing, so it must not itself become
the thing that says nothing: `argparse`'s `choices=` would exit 2 on a
status this table has not heard of, and the log would never be posted.
"""
key = (status or "").strip().lower()
if not key:
key = DEFAULT_STATUS
if key in STATUS_PHRASES:
return STATUS_PHRASES[key]
# Backticks removed, not escaped: the verbatim value goes inside a code
# span in a **bold** header, and a backtick in it closes the span early --
# the rest of the status then renders as markdown. Nothing hostile is
# expected here (`${{ job.status }}` is written by whoever wrote the
# workflow), but this repo is public and consumed by four others, and a
# branch name or matrix value could reach this argument later.
return "finished with status `" + status.strip().replace("`", "") + "`"
def missing_log_note(path, phrase):
"""What to say when the log file the step named is not there."""
if phrase == "failed":
return (f"The step produced no log file at `{path}` — it failed "
"before the build started.")
return (f"The step produced no log file at `{path}` — nothing was "
"captured.")
def pull_request_number(event_path):
"""The PR this job is running for, from the event payload, or None."""
if not event_path or not os.path.exists(event_path):
return None
with open(event_path) as fh:
event = json.load(fh)
pr = event.get("pull_request") or {}
return pr.get("number")
def pull_request_for_branch(host, repo, token, branch):
"""The open PR whose head is `branch`, or None.
Only a `pull_request` event carries the PR in its payload. A
`workflow_dispatch` or `schedule` run does not, and the first version of
this script simply printed "not a pull request build" and exited 0 -- so a
dispatched run that failed stayed exactly as silent as the one this script
exists to fix. Looking the branch up covers those.
Only finds an **open** PR. A push whose branch's PR has already merged and
closed (the common shape for `development`/`main`) is `pull_request_for_sha`'s
job, tried next.
"""
if not branch:
return None
url = (f"{host.rstrip('/')}/api/v1/repos/{repo}/pulls"
f"?state=open&limit=50")
req = urllib.request.Request(url)
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req, timeout=30) as res:
pulls = json.loads(res.read() or b"[]")
except (urllib.error.URLError, OSError, ValueError):
# A reporter that raises reports nothing. HTTPError is a URLError, and
# ValueError covers a body that is not JSON.
return None
for pull in pulls:
if ((pull.get("head") or {}).get("ref")) == branch:
return pull.get("number")
return None
def pull_request_for_sha(host, repo, token, sha):
"""The PR that produced this commit, open or closed, or None.
Covers what `pull_request_for_branch` cannot: a `push` to
`development`/`main` is almost always a merge landing, and by the time
the job runs, the PR that produced it is already **closed** -- so no
*open* PR has the pushed branch as its head (weblib-ci#14, found via
weblib-viewer#27). Gitea still remembers which PR a given commit came
from, independent of the PR's state:
`GET repos/{repo}/commits/{sha}/pull`. Verified against both merge
styles used in this org -- an explicit merge commit (weblib-ci#13) and a
fast-forward promotion, where the "merge commit" is just the PR branch's
own tip (weblib-fs#27) -- both resolve correctly.
This needs the **full** 40-character sha. A short/abbreviated sha (the
kind used for display, e.g. `GITHUB_SHA[:8]`) gets exactly the same
`{"message": "pull request does not exist [id: 0, ...]"}` 404 shape as a
commit that genuinely has no PR -- so passing one silently looks
identical to "not found" rather than erroring. `GITHUB_SHA` itself is
already the full sha; this just must not be truncated before it gets
here.
"""
if not sha:
return None
url = f"{host.rstrip('/')}/api/v1/repos/{repo}/commits/{sha}/pull"
req = urllib.request.Request(url)
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req, timeout=30) as res:
pull = json.loads(res.read() or b"null")
except (urllib.error.URLError, OSError, ValueError):
# Same reasoning as pull_request_for_branch: a 404 (no PR for this
# commit) is the expected outcome for most calls, not an error to
# surface, and a reporter that raises reports nothing.
return None
return (pull or {}).get("number")
def tail(path, limit):
with open(path, "rb") as fh:
try:
fh.seek(0, os.SEEK_END)
size = fh.tell()
fh.seek(max(0, size - limit * 4))
except OSError: # not seekable; read it all
size = None
raw = fh.read()
text = ANSI.sub("", raw.decode("utf-8", "replace"))
if len(text) > limit:
text = text[-limit:]
# Do not start mid-line; it reads as corruption rather than truncation.
text = text.split("\n", 1)[-1]
text = "[…truncated…]\n" + text
return text
def comment(host, repo, number, token, body):
url = f"{host.rstrip('/')}/api/v1/repos/{repo}/issues/{number}/comments"
req = urllib.request.Request(url, data=json.dumps({"body": body}).encode(),
method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", f"token {token}")
with urllib.request.urlopen(req, timeout=30) as res:
return json.loads(res.read() or b"null")
def status_description(job, phrase, raw_text, limit=STATUS_TAIL):
"""The one-line-ish body for a commit status, when there is no PR to post
the full log to. `raw_text` is plain text (no code fence, no ANSI) --
either a log tail or `missing_log_note`'s message."""
snippet = (raw_text or "").strip().replace("\n", " ").strip()
if len(snippet) > limit:
snippet = "…" + snippet[-limit:]
return f"{job} {phrase}: {snippet}" if snippet else f"{job} {phrase}"
def report_status(host, repo, sha, token, state, description, target_url):
url = f"{host.rstrip('/')}/api/v1/repos/{repo}/statuses/{sha}"
payload = {"context": "report_job_log", "state": state,
"description": description, "target_url": target_url}
req = urllib.request.Request(url, data=json.dumps(payload).encode(),
method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", f"token {token}")
with urllib.request.urlopen(req, timeout=30) as res:
return json.loads(res.read() or b"null")
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("log", help="path to the captured build log")
ap.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY"))
ap.add_argument("--host", default=os.environ.get("GITEA_HOST")
or os.environ.get("GITHUB_SERVER_URL") or DEFAULT_HOST)
ap.add_argument("--token", default=os.environ.get("GITEA_TOKEN")
or os.environ.get("GITHUB_TOKEN"))
ap.add_argument("--pr", type=int, default=None)
ap.add_argument("--job", default=os.environ.get("GITHUB_JOB") or "job")
ap.add_argument("--tail", type=int, default=DEFAULT_TAIL)
ap.add_argument("--status",
default=os.environ.get("JOB_STATUS") or DEFAULT_STATUS,
help="outcome of the job being reported: failed (the "
"default, and what `if: failure()` means), passed, "
"cancelled, skipped, or ${{ job.status }} verbatim")
args = ap.parse_args(argv)
if not args.repo or not args.token:
print("no repo or no token; set GITHUB_REPOSITORY and GITEA_TOKEN")
return 0
full_sha = os.environ.get("GITHUB_SHA") or ""
sha = full_sha[:8]
number = args.pr or pull_request_number(os.environ.get("GITHUB_EVENT_PATH"))
if number is None:
branch = (os.environ.get("GITHUB_HEAD_REF")
or os.environ.get("GITHUB_REF_NAME"))
number = pull_request_for_branch(args.host, args.repo, args.token, branch)
if number is None:
# A push to development/main has no *open* PR for the branch it
# pushed to -- that branch is the target, not the one that was
# merged and closed. Ask Gitea which PR this exact commit came from
# instead (weblib-ci#14).
number = pull_request_for_sha(args.host, args.repo, args.token, full_sha)
phrase = status_phrase(args.status)
log_exists = os.path.exists(args.log)
if not log_exists:
raw = missing_log_note(args.log, phrase)
else:
raw = tail(args.log, args.tail).rstrip()
if number is not None:
text = ("```\n" + raw + "\n```") if log_exists else raw
body = (f"**`{args.job}` {phrase}**{f' on `{sha}`' if sha else ''}.\n\n"
"Job logs return 500 on this Gitea, so here is the tail of the "
"build output, posted by [`report_job_log.py`]"
"(https://git.chaosbit.de/weblib/weblib-ci).\n\n"
f"{text}\n")
# A failure to report a failure must not itself be silent, but it also
# must not mask the real one: the step is already `if: failure()`.
try:
result = comment(args.host, args.repo, number, args.token, body)
except urllib.error.HTTPError as e:
print(f"could not comment: {e.code} "
f"{e.read()[:300].decode('utf-8', 'replace')}", file=sys.stderr)
return 0
print(f"reported to {args.repo}#{number}: {(result or {}).get('html_url')}")
return 0
# No PR anywhere -- a commit that was never part of one (a direct push,
# or the lookups above genuinely found nothing). There is still no log
# endpoint to point at, but a commit status is a channel that does not
# need a thread to post into, and this repo's Actions already post
# statuses that people read.
if not full_sha:
print("no pull request and no commit sha for this run: nothing to "
"report")
return 0
state = STATUS_STATE.get(phrase, "error")
description = status_description(args.job, phrase, raw)
target_url = f"{args.host.rstrip('/')}/{args.repo}/commit/{full_sha}"
try:
report_status(args.host, args.repo, full_sha, args.token, state,
description, target_url)
except urllib.error.HTTPError as e:
print(f"could not report status: {e.code} "
f"{e.read()[:300].decode('utf-8', 'replace')}", file=sys.stderr)
return 0
print(f"reported to {args.repo}@{sha} as a commit status "
"(no pull request found)")
return 0
if __name__ == "__main__":
sys.exit(main())