Compare commits
10 Commits
061d8b266c
...
fix/report
| Author | SHA1 | Date | |
|---|---|---|---|
| 13e396a795 | |||
| 028c162874 | |||
| be73235c01 | |||
| 67b133a115 | |||
| 259bdd93ca | |||
| e6f18b5a3b | |||
| 3a0d8db656 | |||
| f9777ccbd2 | |||
| 9c26afd8d2 | |||
| d41e4ca2c6 |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# The test suite imports report_job_log, and a tracked .pyc of it went stale
|
||||
# the moment the source changed.
|
||||
__pycache__/
|
||||
*.pyc
|
||||
86
README.md
86
README.md
@@ -14,33 +14,89 @@ fetch it — which was measured to be the difference between one step and three.
|
||||
| file | what it does |
|
||||
|---|---|
|
||||
| `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. |
|
||||
| `sync_blocked_label.py` | Keeps `Status/Blocked` in step with Gitea's dependency graph. |
|
||||
| `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. Takes `--status`; see below. |
|
||||
| `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
|
||||
this repo has no flake.
|
||||
|
||||
## Using it
|
||||
`test_report_job_log.py` covers the reporter. It is standard library and
|
||||
offline — the forge it posts to is an `http.server` on localhost that keeps
|
||||
what it is sent, so a test reads the comment back rather than trusting an exit
|
||||
status of 0, which this script returns even when the POST failed.
|
||||
|
||||
`with-nixpkgs.sh` reads the **consuming** repo's `flake.lock` relative to the
|
||||
working directory, so it keeps working from a subdirectory.
|
||||
```bash
|
||||
python3 test_report_job_log.py # 21 tests, ~1.2s, no network
|
||||
```
|
||||
|
||||
There is no workflow running it: this repo has no `.gitea/workflows` at all,
|
||||
and no `flake.lock` for `with-nixpkgs.sh` to read. Run it by hand before
|
||||
pushing.
|
||||
|
||||
## Using it
|
||||
|
||||
```yaml
|
||||
- uses: actions/checkout@v4
|
||||
- name: fetch the shared CI tools
|
||||
run: git clone --depth 1 https://git.chaosbit.de/weblib/weblib-ci.git .ci
|
||||
- run: bash .ci/with-nixpkgs.sh python3 python3 .ci/report_job_log.py /tmp/build.log
|
||||
- id: ci
|
||||
uses: https://git.chaosbit.de/weblib/weblib-ci@main
|
||||
- run: bash ${{ steps.ci.outputs.path }}/with-nixpkgs.sh python3 \
|
||||
python3 ${{ steps.ci.outputs.path }}/report_job_log.py /tmp/build.log
|
||||
```
|
||||
|
||||
No credentials: the repo is public, which is the whole point of it being so.
|
||||
No credentials anywhere: the repo is public, which is the point of it being so.
|
||||
|
||||
### Why not `uses:`
|
||||
`with-nixpkgs.sh` reads the **consuming** repo's `flake.lock` relative to the
|
||||
working directory, so it keeps working when invoked by absolute path from
|
||||
outside the checkout.
|
||||
|
||||
`uses:` pointing at a repo on this instance was measured on weblib-archive#44
|
||||
and did not work, in either the bare `weblib/weblib-ci@main` form or with a
|
||||
full URL — while a plain clone with the same access did. `action.yml` is kept
|
||||
here so the question can be re-checked cheaply if the instance changes; the
|
||||
README records the answer so nobody has to re-derive it.
|
||||
### Use the full URL, not `weblib/weblib-ci@main`
|
||||
|
||||
Measured on weblib-archive#44 (2026-09-07), one job per form because Gitea
|
||||
posts one commit status per job and job logs return 500:
|
||||
|
||||
| form | result |
|
||||
|---|---|
|
||||
| `uses: https://git.chaosbit.de/weblib/weblib-ci@main` | **works** |
|
||||
| `uses: weblib/weblib-ci@main` | **fails** |
|
||||
| `git clone https://…/weblib-ci.git` with no credentials | works |
|
||||
| `steps.<id>.outputs.path`, then running a tool through it | works |
|
||||
|
||||
The bare `owner/repo` form resolves against the instance's default actions URL
|
||||
rather than this host, so it has to be the full URL. Both forms failed while
|
||||
this repo was **private**, which is the other half of why it is public — the
|
||||
alternative was a deploy key and an ssh setup step in four repos.
|
||||
|
||||
The `outputs.path` row is listed separately on purpose: the action *running* and
|
||||
its output *reaching the caller* are different claims, and a composite action
|
||||
returning an empty string is exactly the sort of thing that looks green.
|
||||
|
||||
### `report_job_log.py --status`
|
||||
|
||||
The header used to be hardcoded to *failed*. That is true of every caller here,
|
||||
because each guards the step with `if: failure()` — but a probe run under
|
||||
`if: always()` posted a failure report for a job that had passed
|
||||
(weblib-viewer#10, filed as #8).
|
||||
|
||||
**The default is still `failed`**, so a caller passing only the log path is
|
||||
unchanged. A step that can run on success has to say so:
|
||||
|
||||
```yaml
|
||||
- name: report the log
|
||||
if: always()
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
bash "${{ steps.ci.outputs.path }}/with-nixpkgs.sh" python3 \
|
||||
python3 "${{ steps.ci.outputs.path }}/report_job_log.py" /tmp/build.log \
|
||||
--status "${{ job.status }}"
|
||||
```
|
||||
|
||||
`${{ job.status }}` yields `success`/`failure`/`cancelled`/`skipped`, so those
|
||||
spellings are accepted alongside `passed`/`failed`. `JOB_STATUS` in the
|
||||
environment does the same thing if a flag is awkward. An **unrecognised**
|
||||
status is put in the header verbatim rather than rejected: `argparse`'s
|
||||
`choices=` would exit 2 on a value this list has not heard of, and the log —
|
||||
the entire reason the script exists — would never be posted.
|
||||
|
||||
### Why not a flake input
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
# Makes the shared tools available to a workflow and reports where they are.
|
||||
#
|
||||
# Whether this is usable at all depends on `uses:` resolving a repo on this
|
||||
# Gitea, which is instance configuration rather than something a repo controls.
|
||||
# Measured on weblib-archive#44 while weblib-ci was still private: it did not
|
||||
# work. Re-measured once the repo was public - see the README for the outcome
|
||||
# and for the fallback, which is a plain clone and always works.
|
||||
# Consume it with the **full URL** - `uses: https://git.chaosbit.de/weblib/
|
||||
# weblib-ci@main`. The bare `weblib/weblib-ci@main` form resolves against the
|
||||
# instance's default actions URL rather than this host and fails. Both forms
|
||||
# failed while this repo was private; see the README table.
|
||||
name: weblib CI tools
|
||||
description: Shared CI scripts for the weblib repos.
|
||||
|
||||
|
||||
@@ -9,13 +9,24 @@ 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`, and
|
||||
`GITHUB_SERVER_URL`.
|
||||
`GITHUB_EVENT_PATH` (for the PR number), `GITEA_TOKEN`/`GITHUB_TOKEN`,
|
||||
`GITHUB_SERVER_URL`, and `JOB_STATUS` if `--status` is not passed.
|
||||
|
||||
Remove this once the instance is updated and logs can be read directly.
|
||||
"""
|
||||
@@ -37,6 +48,57 @@ 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]")
|
||||
|
||||
# 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",
|
||||
}
|
||||
|
||||
|
||||
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."""
|
||||
@@ -119,6 +181,11 @@ def main(argv=None):
|
||||
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:
|
||||
@@ -134,16 +201,18 @@ def main(argv=None):
|
||||
print("no pull request for this run: nothing to comment on")
|
||||
return 0
|
||||
|
||||
phrase = status_phrase(args.status)
|
||||
|
||||
if not os.path.exists(args.log):
|
||||
text = ("The step produced no log file at "
|
||||
f"`{args.log}` — it failed before the build started.")
|
||||
text = missing_log_note(args.log, phrase)
|
||||
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"
|
||||
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 `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")
|
||||
|
||||
# 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_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:
|
||||
def __init__(self, base, token):
|
||||
@@ -75,15 +87,29 @@ class Forge:
|
||||
|
||||
|
||||
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:
|
||||
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"):
|
||||
if label["name"] == name:
|
||||
return label["id"]
|
||||
owner = repo.split("/")[0]
|
||||
for path in (f"repos/{repo}/labels?limit=100",
|
||||
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
|
||||
|
||||
|
||||
@@ -110,6 +136,15 @@ def reconcile(forge, repo, label_name, dry_run):
|
||||
changed = []
|
||||
for item in open_items(forge, repo):
|
||||
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 []
|
||||
if not deps:
|
||||
# No edges, no opinion. See the docstring.
|
||||
|
||||
331
test_report_job_log.py
Executable file
331
test_report_job_log.py
Executable file
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for `report_job_log.py`.
|
||||
|
||||
Standard library only, like everything else here, and offline: the Gitea it
|
||||
posts to is a `http.server` on localhost that keeps the comments it is sent, so
|
||||
a test can *re-read* what was written instead of trusting an exit code of 0.
|
||||
|
||||
Run them with any python3:
|
||||
|
||||
python3 test_report_job_log.py
|
||||
|
||||
The point of most of them is the comment **header**. `report_job_log.py` used
|
||||
to hardcode the word "failed", so a step guarded by `if: always()` posted a
|
||||
failure report for a job that had passed (weblib-ci#8, found in
|
||||
weblib-viewer#10). The header is the one thing a reader sees before the log, so
|
||||
it is the one thing worth asserting on.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import report_job_log
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SCRIPT = os.path.join(HERE, "report_job_log.py")
|
||||
|
||||
# Environment variables the script reads. Every test clears all of them, so a
|
||||
# stray one in the ambient shell (this suite is meant to be runnable on a
|
||||
# runner, where several of these are set) cannot change a result.
|
||||
ACTIONS_ENV = [
|
||||
"GITHUB_REPOSITORY", "GITHUB_EVENT_PATH", "GITHUB_SERVER_URL",
|
||||
"GITHUB_SHA", "GITHUB_JOB", "GITHUB_HEAD_REF", "GITHUB_REF_NAME",
|
||||
"GITEA_HOST", "GITEA_TOKEN", "GITHUB_TOKEN", "JOB_STATUS",
|
||||
]
|
||||
|
||||
|
||||
class FakeGitea(BaseHTTPRequestHandler):
|
||||
"""Just enough of the API: list open PRs, and accept a comment."""
|
||||
|
||||
comments = [] # class-level; reset per test
|
||||
pulls = []
|
||||
|
||||
def _send(self, code, payload):
|
||||
raw = json.dumps(payload).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def do_GET(self):
|
||||
if "/pulls" in self.path:
|
||||
return self._send(200, type(self).pulls)
|
||||
self._send(404, {"message": "no"})
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||
type(self).comments.append({
|
||||
"path": self.path,
|
||||
"auth": self.headers.get("Authorization"),
|
||||
"body": payload.get("body", ""),
|
||||
})
|
||||
self._send(201, {"html_url": "http://example.invalid/c/1"})
|
||||
|
||||
def log_message(self, *a): # keep the test output readable
|
||||
pass
|
||||
|
||||
|
||||
class ServerTestCase(unittest.TestCase):
|
||||
"""A test that runs the whole entry point against the fake forge."""
|
||||
|
||||
def setUp(self):
|
||||
FakeGitea.comments = []
|
||||
FakeGitea.pulls = []
|
||||
self.server = HTTPServer(("127.0.0.1", 0), FakeGitea)
|
||||
self.host = "http://127.0.0.1:%d" % self.server.server_address[1]
|
||||
# `shutdown()` only takes effect on the next poll, so the default
|
||||
# 0.5s interval charged this suite half a second per test -- measured
|
||||
# 4.6s for nine tests, 0.6s after.
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, kwargs={"poll_interval": 0.01},
|
||||
daemon=True)
|
||||
self.thread.start()
|
||||
self.addCleanup(self.server.server_close)
|
||||
self.addCleanup(self.server.shutdown)
|
||||
|
||||
self.log = os.path.join(self.mkdtemp(), "build.log")
|
||||
with open(self.log, "w") as fh:
|
||||
fh.write("nix-build \x1b[31msomething\x1b[0m\nDISTINCTIVE-LINE\n")
|
||||
|
||||
for name in ACTIONS_ENV:
|
||||
os.environ.pop(name, None)
|
||||
|
||||
def mkdtemp(self):
|
||||
import tempfile
|
||||
d = tempfile.mkdtemp()
|
||||
self.addCleanup(lambda: __import__("shutil").rmtree(d,
|
||||
ignore_errors=True))
|
||||
return d
|
||||
|
||||
def posted(self):
|
||||
"""What the server actually stored, read back from the server.
|
||||
|
||||
An exit status of 0 is not evidence that a comment was written: the
|
||||
script deliberately swallows HTTP errors so that a failure to report
|
||||
does not mask the failure being reported, and returns 0 either way.
|
||||
"""
|
||||
return FakeGitea.comments
|
||||
|
||||
def run_main(self, *argv):
|
||||
"""The entry point in-process, so a traceback is readable."""
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
code = report_job_log.main([
|
||||
self.log, "--repo", "weblib/weblib-ci", "--host", self.host,
|
||||
"--token", "t0ken", "--pr", "8", "--job", "tests", *argv])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("reported to weblib/weblib-ci#8", out.getvalue())
|
||||
return code
|
||||
|
||||
def run_cli(self, *argv, env=None):
|
||||
"""The entry point as a subprocess: argv parsing and shebang included."""
|
||||
full = dict(os.environ)
|
||||
full.update(env or {})
|
||||
proc = subprocess.run(
|
||||
[sys.executable, SCRIPT, self.log, "--repo", "weblib/weblib-ci",
|
||||
"--host", self.host, "--token", "t0ken", "--pr", "8",
|
||||
"--job", "tests", *argv],
|
||||
capture_output=True, text=True, env=full, cwd=HERE)
|
||||
return proc
|
||||
|
||||
|
||||
class TestHeader(ServerTestCase):
|
||||
|
||||
def test_default_is_still_failed(self):
|
||||
"""Backward compatibility: the four repos pass only the log path."""
|
||||
self.run_main()
|
||||
body = self.posted()[0]["body"]
|
||||
self.assertIn("**`tests` failed**", body)
|
||||
self.assertNotIn("passed", body)
|
||||
|
||||
def test_status_passed(self):
|
||||
self.run_main("--status", "passed")
|
||||
body = self.posted()[0]["body"]
|
||||
self.assertIn("**`tests` passed**", body)
|
||||
self.assertNotIn("failed", body)
|
||||
|
||||
def test_job_status_expression_success(self):
|
||||
"""`${{ job.status }}` yields `success`, not `passed`."""
|
||||
self.run_main("--status", "success")
|
||||
self.assertIn("**`tests` passed**", self.posted()[0]["body"])
|
||||
|
||||
def test_job_status_expression_failure(self):
|
||||
self.run_main("--status", "failure")
|
||||
self.assertIn("**`tests` failed**", self.posted()[0]["body"])
|
||||
|
||||
def test_cancelled(self):
|
||||
self.run_main("--status", "cancelled")
|
||||
self.assertIn("**`tests` was cancelled**", self.posted()[0]["body"])
|
||||
|
||||
def test_case_and_whitespace_are_not_a_failure_report(self):
|
||||
self.run_main("--status", " Success \n")
|
||||
self.assertIn("**`tests` passed**", self.posted()[0]["body"])
|
||||
|
||||
def test_unknown_status_still_reports(self):
|
||||
"""A status the table has not heard of must not cost us the log.
|
||||
|
||||
`argparse(choices=...)` would exit 2 here, and the log this script
|
||||
exists to surface would never be posted.
|
||||
"""
|
||||
self.run_main("--status", "neutral")
|
||||
body = self.posted()[0]["body"]
|
||||
self.assertIn("**`tests` finished with status `neutral`**", body)
|
||||
self.assertIn("DISTINCTIVE-LINE", body)
|
||||
|
||||
def test_empty_status_falls_back_to_the_default(self):
|
||||
self.run_main("--status", "")
|
||||
self.assertIn("**`tests` failed**", self.posted()[0]["body"])
|
||||
|
||||
def test_sha_and_log_survive_a_non_default_status(self):
|
||||
os.environ["GITHUB_SHA"] = "0123456789abcdef"
|
||||
self.run_main("--status", "passed")
|
||||
body = self.posted()[0]["body"]
|
||||
self.assertIn("**`tests` passed** on `01234567`.", body)
|
||||
self.assertIn("DISTINCTIVE-LINE", body)
|
||||
self.assertNotIn("\x1b[31m", body) # ANSI still stripped
|
||||
|
||||
|
||||
class TestEnvironment(ServerTestCase):
|
||||
|
||||
def test_job_status_env_is_honoured(self):
|
||||
proc = self.run_cli(env={"JOB_STATUS": "success"})
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertIn("**`tests` passed**", self.posted()[0]["body"])
|
||||
|
||||
def test_flag_beats_the_environment(self):
|
||||
proc = self.run_cli("--status", "failed", env={"JOB_STATUS": "success"})
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertIn("**`tests` failed**", self.posted()[0]["body"])
|
||||
|
||||
def test_unset_environment_is_still_failed(self):
|
||||
proc = self.run_cli()
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertIn("**`tests` failed**", self.posted()[0]["body"])
|
||||
|
||||
|
||||
class TestMissingLog(ServerTestCase):
|
||||
|
||||
def test_missing_log_when_failed(self):
|
||||
self.log = os.path.join(self.mkdtemp(), "absent.log")
|
||||
self.run_main()
|
||||
self.assertIn("it failed before the build started",
|
||||
self.posted()[0]["body"])
|
||||
|
||||
def test_missing_log_when_passed_does_not_claim_a_failure(self):
|
||||
self.log = os.path.join(self.mkdtemp(), "absent.log")
|
||||
self.run_main("--status", "passed")
|
||||
body = self.posted()[0]["body"]
|
||||
self.assertIn("nothing was captured", body)
|
||||
self.assertNotIn("failed", body)
|
||||
|
||||
|
||||
class TestEndToEnd(ServerTestCase):
|
||||
"""The CLI, driven the way a workflow drives it: environment, no `--pr`."""
|
||||
|
||||
def _event_file(self, number):
|
||||
path = os.path.join(self.mkdtemp(), "event.json")
|
||||
with open(path, "w") as fh:
|
||||
json.dump({"pull_request": {"number": number}}, fh)
|
||||
return path
|
||||
|
||||
def _run(self, *argv, env=None):
|
||||
full = dict(os.environ)
|
||||
full.pop("GITEA_HOST", None)
|
||||
full.update({
|
||||
"GITHUB_REPOSITORY": "weblib/weblib-ci",
|
||||
"GITHUB_SERVER_URL": self.host,
|
||||
"GITEA_TOKEN": "t0ken",
|
||||
"GITHUB_JOB": "probe",
|
||||
"GITHUB_SHA": "abcdef0123456789",
|
||||
"GITHUB_EVENT_PATH": self._event_file(42),
|
||||
})
|
||||
full.update(env or {})
|
||||
return subprocess.run([sys.executable, SCRIPT, self.log, *argv],
|
||||
capture_output=True, text=True, env=full,
|
||||
cwd=HERE)
|
||||
|
||||
def test_always_guarded_probe_that_passed(self):
|
||||
proc = self._run("--status", "success")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertIn("reported to weblib/weblib-ci#42", proc.stdout)
|
||||
|
||||
# The write is only real if the resource says so.
|
||||
posted = self.posted()
|
||||
self.assertEqual(len(posted), 1, posted)
|
||||
self.assertEqual(posted[0]["path"],
|
||||
"/api/v1/repos/weblib/weblib-ci/issues/42/comments")
|
||||
self.assertEqual(posted[0]["auth"], "token t0ken")
|
||||
body = posted[0]["body"]
|
||||
self.assertIn("**`probe` passed** on `abcdef01`.", body)
|
||||
self.assertNotIn("failed", body)
|
||||
self.assertIn("DISTINCTIVE-LINE", body)
|
||||
|
||||
def test_failure_guarded_job_is_unchanged(self):
|
||||
proc = self._run()
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertIn("**`probe` failed** on `abcdef01`.",
|
||||
self.posted()[0]["body"])
|
||||
|
||||
def test_branch_lookup_path_still_works(self):
|
||||
"""No event payload: the PR is found by head branch, and reported."""
|
||||
FakeGitea.pulls = [{"number": 7, "head": {"ref": "fix/x"}}]
|
||||
proc = self._run("--status", "passed",
|
||||
env={"GITHUB_EVENT_PATH": "",
|
||||
"GITHUB_HEAD_REF": "fix/x"})
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertIn("reported to weblib/weblib-ci#7", proc.stdout)
|
||||
self.assertIn("**`probe` passed**", self.posted()[0]["body"])
|
||||
|
||||
def test_help_still_renders(self):
|
||||
"""argparse `%`-expands `help=`; a stray `%` there breaks `--help`."""
|
||||
proc = subprocess.run([sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True, cwd=HERE)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertIn("--status", proc.stdout)
|
||||
|
||||
|
||||
class TestStatusPhrase(unittest.TestCase):
|
||||
"""The unit under the header, without a server."""
|
||||
|
||||
def test_table(self):
|
||||
for status, want in [
|
||||
("failed", "failed"), ("failure", "failed"),
|
||||
("passed", "passed"), ("success", "passed"),
|
||||
("succeeded", "passed"),
|
||||
("cancelled", "was cancelled"), ("canceled", "was cancelled"),
|
||||
("skipped", "was skipped"),
|
||||
("SUCCESS", "passed"), (" failure ", "failed"),
|
||||
(None, "failed"), ("", "failed"),
|
||||
]:
|
||||
with self.subTest(status=status):
|
||||
self.assertEqual(report_job_log.status_phrase(status), want)
|
||||
|
||||
def test_unknown_is_verbatim(self):
|
||||
self.assertEqual(report_job_log.status_phrase("weird"),
|
||||
"finished with status `weird`")
|
||||
|
||||
def test_a_backtick_in_an_unknown_status_cannot_escape_the_code_span(self):
|
||||
"""The verbatim value sits in a code span inside a **bold** header, so
|
||||
a backtick in it would close the span and let the rest render as
|
||||
markdown. `${{ job.status }}` is workflow-author-controlled rather than
|
||||
hostile, but this script is public and shared by four repos."""
|
||||
phrase = report_job_log.status_phrase("x` **loud** `y")
|
||||
self.assertEqual(phrase, "finished with status `x **loud** y`")
|
||||
self.assertEqual(phrase.count("`"), 2)
|
||||
|
||||
def test_default_constant_is_failed(self):
|
||||
"""Named so that changing it is a deliberate act, not a typo."""
|
||||
self.assertEqual(report_job_log.DEFAULT_STATUS, "failed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user