#!/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. Usage, from a workflow step guarded by `if: failure()`: report_job_log.py /tmp/build.log Everything else comes from the Actions environment: `GITHUB_REPOSITORY`, `GITHUB_EVENT_PATH` (for the PR number), `GITEA_TOKEN`/`GITHUB_TOKEN`, and `GITHUB_SERVER_URL`. 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]") 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. A push to a branch with no PR still has nowhere to comment, which is honest: there is no thread for it. """ 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 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 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) 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 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: print("no pull request for this run: nothing to comment on") return 0 if not os.path.exists(args.log): text = ("The step produced no log file at " f"`{args.log}` — it failed before the build started.") else: text = "```\n" + tail(args.log, args.tail).rstrip() + "\n```" sha = (os.environ.get("GITHUB_SHA") or "")[:8] 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 " "build output, posted by `tools/report_job_log.py`.\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 if __name__ == "__main__": sys.exit(main())