Give a push-triggered job somewhere to report a failure
report_job_log live-smoke-test passed: live smoke test log line REGRESSION-CHECK-LIVE-SMOKE
report_job_log live-smoke-test passed: live smoke test log line REGRESSION-CHECK-LIVE-SMOKE
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>
This commit is contained in:
@@ -14,7 +14,7 @@ fetch it — which was measured to be the difference between one step and three.
|
|||||||
| file | what it does |
|
| 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. |
|
| `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. Takes `--status`; see below. |
|
| `report_job_log.py` | Posts the tail of a build log as a PR comment, falling back to a commit status when a `push`-triggered job has no PR to comment on. 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*, never touches an issue marked `Status/On Hold` or `Status/Abandoned`, and leaves the label alone (rather than guessing) on a blocker whose state a repo-scoped token cannot resolve — see weblib-ci#11. |
|
| `sync_blocked_label.py` | Keeps `Status/Blocked` in step with Gitea's dependency graph. Resolves the label from the repo *or the organisation*, never touches an issue marked `Status/On Hold` or `Status/Abandoned`, and leaves the label alone (rather than guessing) on a blocker whose state a repo-scoped token cannot resolve — see weblib-ci#11. |
|
||||||
|
|
||||||
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
|
||||||
@@ -27,7 +27,7 @@ test reads state back rather than trusting an exit status of 0 (which the
|
|||||||
reporter in particular returns even when its POST failed).
|
reporter in particular returns even when its POST failed).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 test_report_job_log.py # 22 tests, ~1.2s, no network
|
python3 test_report_job_log.py # 28 tests, ~3s, no network
|
||||||
python3 test_sync_blocked_label.py # 12 tests, no network
|
python3 test_sync_blocked_label.py # 12 tests, no network
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -100,6 +100,50 @@ 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 —
|
`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.
|
the entire reason the script exists — would never be posted.
|
||||||
|
|
||||||
|
### `push`-triggered jobs: no open PR does not mean no channel
|
||||||
|
|
||||||
|
Found the hard way on weblib-ci#14: a `push` to `development`/`main` — the
|
||||||
|
common shape of a workflow like a publish pipeline, which has no
|
||||||
|
`pull_request` trigger at all — has no *open* PR for `report_job_log.py` to
|
||||||
|
comment on. The branch that was pushed to is the merge's **target**, not the
|
||||||
|
PR that produced it, and that PR is already closed. The old behaviour was to
|
||||||
|
print "no pull request for this run" and exit 0, so a failure there was
|
||||||
|
completely invisible: not the log (500 on this Gitea), not a comment
|
||||||
|
(nowhere to put it), nothing. That is how weblib-viewer run 1769's failure
|
||||||
|
(`publish.sh` refusing to overwrite an existing APK) went unnoticed until it
|
||||||
|
was reproduced by hand.
|
||||||
|
|
||||||
|
Two things changed, tried in order, neither needing a new flag:
|
||||||
|
|
||||||
|
1. **Ask Gitea which PR produced this exact commit.**
|
||||||
|
`GET repos/{repo}/commits/{sha}/pull` resolves the PR a commit came from —
|
||||||
|
open or closed — keyed on the commit itself rather than a branch name.
|
||||||
|
Verified against both merge styles used in this org: an explicit merge
|
||||||
|
commit, and a fast-forward promotion where the "merge commit" is just the
|
||||||
|
PR branch's own tip. This needs the **full** 40-character `GITHUB_SHA`; a
|
||||||
|
short sha gets the same 404 shape as a commit with no PR at all, so it
|
||||||
|
would silently look like "not found". This covers the ordinary case —
|
||||||
|
almost every push to a protected branch here is a merge landing.
|
||||||
|
2. **If that also finds nothing** — a commit that was never part of any
|
||||||
|
PR — post a **commit status** instead
|
||||||
|
(`POST repos/{repo}/statuses/{sha}`, context `report_job_log`). It is not
|
||||||
|
a substitute for the full log: this Gitea did not truncate a 3000-character
|
||||||
|
description in testing, but a status is UI chrome, not a page, so what
|
||||||
|
goes there is deliberately a short snippet (`STATUS_TAIL`, 500 characters),
|
||||||
|
not the whole tail. `state` follows the same outcome as the header phrase
|
||||||
|
(`failed → failure`, `passed → success`, `cancelled → warning`,
|
||||||
|
`skipped → skipped`; an unrecognised status maps to `error` rather than
|
||||||
|
guessing pass or fail), and `target_url` points at the commit.
|
||||||
|
|
||||||
|
**A commit *comment* API (GitHub-style, `commits/{sha}/comments`) does not
|
||||||
|
exist on this Gitea** — 404, and absent from `/swagger.v1.json` entirely, not
|
||||||
|
merely unauthorised. That ruled out the most direct-looking option; the
|
||||||
|
commit-status endpoint is the one that is actually there.
|
||||||
|
|
||||||
|
If a PR *is* found (by either the original lookups or the new sha lookup),
|
||||||
|
behaviour is identical to before: a comment, same format. The status path is
|
||||||
|
only reached when no PR exists anywhere for the commit.
|
||||||
|
|
||||||
### Why not a flake input
|
### Why not a flake input
|
||||||
|
|
||||||
These are scripts a workflow runs, not derivations. A flake input would cost a
|
These are scripts a workflow runs, not derivations. A flake input would cost a
|
||||||
|
|||||||
+139
-18
@@ -28,6 +28,16 @@ Everything else comes from the Actions environment: `GITHUB_REPOSITORY`,
|
|||||||
`GITHUB_EVENT_PATH` (for the PR number), `GITEA_TOKEN`/`GITHUB_TOKEN`,
|
`GITHUB_EVENT_PATH` (for the PR number), `GITEA_TOKEN`/`GITHUB_TOKEN`,
|
||||||
`GITHUB_SERVER_URL`, and `JOB_STATUS` if `--status` is not passed.
|
`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.
|
Remove this once the instance is updated and logs can be read directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -48,6 +58,12 @@ DEFAULT_TAIL = 12000
|
|||||||
# nix colours its output, and the raw escapes are noise in Markdown.
|
# nix colours its output, and the raw escapes are noise in Markdown.
|
||||||
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
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
|
# The header used to hardcode "failed". Every caller guards the step with
|
||||||
# `if: failure()`, so that was true by construction -- until an `if: always()`
|
# `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.
|
# probe in weblib-viewer#10 posted a failure report for a job that had passed.
|
||||||
@@ -68,6 +84,18 @@ STATUS_PHRASES = {
|
|||||||
"skipped": "was skipped",
|
"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):
|
def status_phrase(status):
|
||||||
"""The verb for the comment header.
|
"""The verb for the comment header.
|
||||||
@@ -119,8 +147,9 @@ def pull_request_for_branch(host, repo, token, branch):
|
|||||||
dispatched run that failed stayed exactly as silent as the one this script
|
dispatched run that failed stayed exactly as silent as the one this script
|
||||||
exists to fix. Looking the branch up covers those.
|
exists to fix. Looking the branch up covers those.
|
||||||
|
|
||||||
A push to a branch with no PR still has nowhere to comment, which is
|
Only finds an **open** PR. A push whose branch's PR has already merged and
|
||||||
honest: there is no thread for it.
|
closed (the common shape for `development`/`main`) is `pull_request_for_sha`'s
|
||||||
|
job, tried next.
|
||||||
"""
|
"""
|
||||||
if not branch:
|
if not branch:
|
||||||
return None
|
return None
|
||||||
@@ -141,6 +170,44 @@ def pull_request_for_branch(host, repo, token, branch):
|
|||||||
return None
|
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):
|
def tail(path, limit):
|
||||||
with open(path, "rb") as fh:
|
with open(path, "rb") as fh:
|
||||||
try:
|
try:
|
||||||
@@ -169,6 +236,28 @@ def comment(host, repo, number, token, body):
|
|||||||
return json.loads(res.read() or b"null")
|
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):
|
def main(argv=None):
|
||||||
ap = argparse.ArgumentParser(description=__doc__,
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
@@ -192,38 +281,70 @@ def main(argv=None):
|
|||||||
print("no repo or no token; set GITHUB_REPOSITORY and GITEA_TOKEN")
|
print("no repo or no token; set GITHUB_REPOSITORY and GITEA_TOKEN")
|
||||||
return 0
|
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"))
|
number = args.pr or pull_request_number(os.environ.get("GITHUB_EVENT_PATH"))
|
||||||
if number is None:
|
if number is None:
|
||||||
branch = (os.environ.get("GITHUB_HEAD_REF")
|
branch = (os.environ.get("GITHUB_HEAD_REF")
|
||||||
or os.environ.get("GITHUB_REF_NAME"))
|
or os.environ.get("GITHUB_REF_NAME"))
|
||||||
number = pull_request_for_branch(args.host, args.repo, args.token, branch)
|
number = pull_request_for_branch(args.host, args.repo, args.token, branch)
|
||||||
if number is None:
|
if number is None:
|
||||||
print("no pull request for this run: nothing to comment on")
|
# A push to development/main has no *open* PR for the branch it
|
||||||
return 0
|
# 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)
|
phrase = status_phrase(args.status)
|
||||||
|
|
||||||
if not os.path.exists(args.log):
|
log_exists = os.path.exists(args.log)
|
||||||
text = missing_log_note(args.log, phrase)
|
if not log_exists:
|
||||||
|
raw = missing_log_note(args.log, phrase)
|
||||||
else:
|
else:
|
||||||
text = "```\n" + tail(args.log, args.tail).rstrip() + "\n```"
|
raw = tail(args.log, args.tail).rstrip()
|
||||||
|
|
||||||
sha = (os.environ.get("GITHUB_SHA") or "")[:8]
|
if number is not None:
|
||||||
body = (f"**`{args.job}` {phrase}**{f' on `{sha}`' if sha else ''}.\n\n"
|
text = ("```\n" + raw + "\n```") if log_exists else raw
|
||||||
"Job logs return 500 on this Gitea, so here is the tail of the "
|
body = (f"**`{args.job}` {phrase}**{f' on `{sha}`' if sha else ''}.\n\n"
|
||||||
"build output, posted by [`report_job_log.py`]"
|
"Job logs return 500 on this Gitea, so here is the tail of the "
|
||||||
"(https://git.chaosbit.de/weblib/weblib-ci).\n\n"
|
"build output, posted by [`report_job_log.py`]"
|
||||||
f"{text}\n")
|
"(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
|
# A failure to report a failure must not itself be silent, but it also
|
||||||
# not mask the real one: the step is already `if: failure()`.
|
# 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:
|
try:
|
||||||
result = comment(args.host, args.repo, number, args.token, body)
|
report_status(args.host, args.repo, full_sha, args.token, state,
|
||||||
|
description, target_url)
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
print(f"could not comment: {e.code} "
|
print(f"could not report status: {e.code} "
|
||||||
f"{e.read()[:300].decode('utf-8', 'replace')}", file=sys.stderr)
|
f"{e.read()[:300].decode('utf-8', 'replace')}", file=sys.stderr)
|
||||||
return 0
|
return 0
|
||||||
print(f"reported to {args.repo}#{number}: {(result or {}).get('html_url')}")
|
print(f"reported to {args.repo}@{sha} as a commit status "
|
||||||
|
"(no pull request found)")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+155
-1
@@ -42,10 +42,16 @@ ACTIONS_ENV = [
|
|||||||
|
|
||||||
|
|
||||||
class FakeGitea(BaseHTTPRequestHandler):
|
class FakeGitea(BaseHTTPRequestHandler):
|
||||||
"""Just enough of the API: list open PRs, and accept a comment."""
|
"""Just enough of the API: list open PRs, resolve a commit's merged PR,
|
||||||
|
accept a comment, and accept a commit status."""
|
||||||
|
|
||||||
comments = [] # class-level; reset per test
|
comments = [] # class-level; reset per test
|
||||||
|
statuses = []
|
||||||
pulls = []
|
pulls = []
|
||||||
|
# sha -> pull dict, mirroring GET .../commits/{sha}/pull. A sha absent
|
||||||
|
# from this map 404s, the same shape Gitea returns for a commit that was
|
||||||
|
# never part of any PR.
|
||||||
|
pull_for_sha = {}
|
||||||
|
|
||||||
def _send(self, code, payload):
|
def _send(self, code, payload):
|
||||||
raw = json.dumps(payload).encode()
|
raw = json.dumps(payload).encode()
|
||||||
@@ -56,6 +62,14 @@ class FakeGitea(BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(raw)
|
self.wfile.write(raw)
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
|
if "/commits/" in self.path and self.path.endswith("/pull"):
|
||||||
|
sha = self.path.split("/commits/", 1)[1].rsplit("/pull", 1)[0]
|
||||||
|
pull = type(self).pull_for_sha.get(sha)
|
||||||
|
if pull is None:
|
||||||
|
return self._send(
|
||||||
|
404, {"message": "pull request does not exist "
|
||||||
|
"[id: 0, issue_id: 0]"})
|
||||||
|
return self._send(200, pull)
|
||||||
if "/pulls" in self.path:
|
if "/pulls" in self.path:
|
||||||
return self._send(200, type(self).pulls)
|
return self._send(200, type(self).pulls)
|
||||||
self._send(404, {"message": "no"})
|
self._send(404, {"message": "no"})
|
||||||
@@ -63,6 +77,16 @@ class FakeGitea(BaseHTTPRequestHandler):
|
|||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
if "/statuses/" in self.path:
|
||||||
|
type(self).statuses.append({
|
||||||
|
"path": self.path,
|
||||||
|
"auth": self.headers.get("Authorization"),
|
||||||
|
"context": payload.get("context"),
|
||||||
|
"state": payload.get("state"),
|
||||||
|
"description": payload.get("description", ""),
|
||||||
|
"target_url": payload.get("target_url"),
|
||||||
|
})
|
||||||
|
return self._send(201, {"id": 1, "state": payload.get("state")})
|
||||||
type(self).comments.append({
|
type(self).comments.append({
|
||||||
"path": self.path,
|
"path": self.path,
|
||||||
"auth": self.headers.get("Authorization"),
|
"auth": self.headers.get("Authorization"),
|
||||||
@@ -79,7 +103,9 @@ class ServerTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
FakeGitea.comments = []
|
FakeGitea.comments = []
|
||||||
|
FakeGitea.statuses = []
|
||||||
FakeGitea.pulls = []
|
FakeGitea.pulls = []
|
||||||
|
FakeGitea.pull_for_sha = {}
|
||||||
self.server = HTTPServer(("127.0.0.1", 0), FakeGitea)
|
self.server = HTTPServer(("127.0.0.1", 0), FakeGitea)
|
||||||
self.host = "http://127.0.0.1:%d" % self.server.server_address[1]
|
self.host = "http://127.0.0.1:%d" % self.server.server_address[1]
|
||||||
# `shutdown()` only takes effect on the next poll, so the default
|
# `shutdown()` only takes effect on the next poll, so the default
|
||||||
@@ -115,6 +141,10 @@ class ServerTestCase(unittest.TestCase):
|
|||||||
"""
|
"""
|
||||||
return FakeGitea.comments
|
return FakeGitea.comments
|
||||||
|
|
||||||
|
def posted_statuses(self):
|
||||||
|
"""Same idea as `posted()`, for the commit-status fallback."""
|
||||||
|
return FakeGitea.statuses
|
||||||
|
|
||||||
def run_main(self, *argv):
|
def run_main(self, *argv):
|
||||||
"""The entry point in-process, so a traceback is readable."""
|
"""The entry point in-process, so a traceback is readable."""
|
||||||
out = io.StringIO()
|
out = io.StringIO()
|
||||||
@@ -293,6 +323,130 @@ class TestEndToEnd(ServerTestCase):
|
|||||||
self.assertIn("--status", proc.stdout)
|
self.assertIn("--status", proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
# A commit that landed by merging a now-closed PR into development/main --
|
||||||
|
# weblib-ci#14's actual failure mode. A real 40-char sha, because
|
||||||
|
# pull_request_for_sha silently treats a truncated one the same as "no PR".
|
||||||
|
PUSH_SHA = "c9d6db67d9c20f8ab133f4fb5fea359e9c474274"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPushWithNoOpenPR(ServerTestCase):
|
||||||
|
"""A `push` event: no event payload, no GITHUB_HEAD_REF, and the pushed
|
||||||
|
branch (GITHUB_REF_NAME) is the *target* of a merge, not a PR head -- the
|
||||||
|
exact shape of weblib-ci#14 (weblib-viewer run 1769, PR#27 merged into
|
||||||
|
`development`)."""
|
||||||
|
|
||||||
|
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": "publish",
|
||||||
|
"GITHUB_SHA": PUSH_SHA,
|
||||||
|
"GITHUB_REF_NAME": "development",
|
||||||
|
})
|
||||||
|
full.update(env or {})
|
||||||
|
return subprocess.run([sys.executable, SCRIPT, self.log, *argv],
|
||||||
|
capture_output=True, text=True, env=full,
|
||||||
|
cwd=HERE)
|
||||||
|
|
||||||
|
def test_falls_back_to_the_pr_the_push_merged(self):
|
||||||
|
"""No open PR has `development` as its head -- it never does -- but
|
||||||
|
the merge commit itself resolves to the PR that produced it, even
|
||||||
|
though that PR is now closed."""
|
||||||
|
FakeGitea.pulls = []
|
||||||
|
FakeGitea.pull_for_sha = {PUSH_SHA: {"number": 27, "state": "closed"}}
|
||||||
|
|
||||||
|
proc = self._run()
|
||||||
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||||
|
self.assertIn("reported to weblib/weblib-ci#27", proc.stdout)
|
||||||
|
|
||||||
|
posted = self.posted()
|
||||||
|
self.assertEqual(len(posted), 1, posted)
|
||||||
|
self.assertEqual(posted[0]["path"],
|
||||||
|
"/api/v1/repos/weblib/weblib-ci/issues/27/comments")
|
||||||
|
self.assertIn("DISTINCTIVE-LINE", posted[0]["body"])
|
||||||
|
self.assertEqual(self.posted_statuses(), [],
|
||||||
|
"a resolvable PR must not also get a status")
|
||||||
|
|
||||||
|
def test_branch_lookup_still_wins_when_it_applies(self):
|
||||||
|
"""Ordering: an *open* PR whose head matches the branch is found
|
||||||
|
first, so the (potentially stale) sha lookup is never even tried."""
|
||||||
|
FakeGitea.pulls = [{"number": 5, "head": {"ref": "development"}}]
|
||||||
|
FakeGitea.pull_for_sha = {PUSH_SHA: {"number": 999}}
|
||||||
|
|
||||||
|
proc = self._run()
|
||||||
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||||
|
self.assertIn("reported to weblib/weblib-ci#5", proc.stdout)
|
||||||
|
self.assertNotIn("999", proc.stdout)
|
||||||
|
|
||||||
|
def test_no_pr_anywhere_reports_a_commit_status(self):
|
||||||
|
"""Not even the sha lookup finds anything -- a commit that was never
|
||||||
|
part of any PR. This must still report *somewhere*, not silently
|
||||||
|
exit 0 the way #14 did."""
|
||||||
|
FakeGitea.pulls = []
|
||||||
|
FakeGitea.pull_for_sha = {}
|
||||||
|
|
||||||
|
proc = self._run()
|
||||||
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||||
|
self.assertIn(f"reported to weblib/weblib-ci@{PUSH_SHA[:8]}",
|
||||||
|
proc.stdout)
|
||||||
|
self.assertEqual(self.posted(), [], "no PR exists to comment on")
|
||||||
|
|
||||||
|
statuses = self.posted_statuses()
|
||||||
|
self.assertEqual(len(statuses), 1, statuses)
|
||||||
|
s = statuses[0]
|
||||||
|
self.assertEqual(s["path"],
|
||||||
|
f"/api/v1/repos/weblib/weblib-ci/statuses/{PUSH_SHA}")
|
||||||
|
self.assertEqual(s["auth"], "token t0ken")
|
||||||
|
self.assertEqual(s["state"], "failure")
|
||||||
|
self.assertIn("publish", s["description"])
|
||||||
|
self.assertIn("DISTINCTIVE-LINE", s["description"])
|
||||||
|
self.assertIn(PUSH_SHA, s["target_url"])
|
||||||
|
|
||||||
|
def test_commit_status_state_follows_the_reported_outcome(self):
|
||||||
|
FakeGitea.pulls = []
|
||||||
|
FakeGitea.pull_for_sha = {}
|
||||||
|
|
||||||
|
for status, want_state in [("passed", "success"),
|
||||||
|
("cancelled", "warning"),
|
||||||
|
("skipped", "skipped"),
|
||||||
|
("neutral", "error")]:
|
||||||
|
with self.subTest(status=status):
|
||||||
|
FakeGitea.statuses = []
|
||||||
|
proc = self._run("--status", status)
|
||||||
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||||
|
self.assertEqual(self.posted_statuses()[0]["state"], want_state)
|
||||||
|
|
||||||
|
def test_no_sha_and_no_pr_does_not_crash_or_post_anything(self):
|
||||||
|
"""The one case with nowhere at all to report: still exits 0 and
|
||||||
|
says so, rather than raising or posting a status with an empty sha
|
||||||
|
in the URL."""
|
||||||
|
FakeGitea.pulls = []
|
||||||
|
FakeGitea.pull_for_sha = {}
|
||||||
|
|
||||||
|
proc = self._run(env={"GITHUB_SHA": "", "GITHUB_REF_NAME": "development"})
|
||||||
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||||
|
self.assertIn("nothing to report", proc.stdout)
|
||||||
|
self.assertEqual(self.posted(), [])
|
||||||
|
self.assertEqual(self.posted_statuses(), [])
|
||||||
|
|
||||||
|
def test_existing_pr_event_path_is_unaffected(self):
|
||||||
|
"""The ordinary pull_request-event path -- unrelated to any of the
|
||||||
|
push-triggered fallback -- still goes straight to the comment, and
|
||||||
|
never touches the sha or status endpoints."""
|
||||||
|
path = os.path.join(self.mkdtemp(), "event.json")
|
||||||
|
with open(path, "w") as fh:
|
||||||
|
json.dump({"pull_request": {"number": 11}}, fh)
|
||||||
|
FakeGitea.pull_for_sha = {PUSH_SHA: {"number": 999}} # must be ignored
|
||||||
|
|
||||||
|
proc = self._run(env={"GITHUB_EVENT_PATH": path})
|
||||||
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||||
|
self.assertIn("reported to weblib/weblib-ci#11", proc.stdout)
|
||||||
|
self.assertEqual(self.posted_statuses(), [])
|
||||||
|
|
||||||
|
|
||||||
class TestStatusPhrase(unittest.TestCase):
|
class TestStatusPhrase(unittest.TestCase):
|
||||||
"""The unit under the header, without a server."""
|
"""The unit under the header, without a server."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user