diff --git a/.github/workflows/test-jira-release-sync.yml b/.github/workflows/test-jira-release-sync.yml new file mode 100644 index 0000000..ad1bcb6 --- /dev/null +++ b/.github/workflows/test-jira-release-sync.yml @@ -0,0 +1,149 @@ +name: Test jira-release-sync + +on: + pull_request: + paths: + - "jira-release-sync/**" + - ".github/workflows/test-jira-release-sync.yml" + push: + branches: [main] + paths: + - "jira-release-sync/**" + - ".github/workflows/test-jira-release-sync.yml" + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + case: + - name: with_keys + release_body: | + ## What's Changed + - PP-100 fix login bug + - PP-101 add new dashboard + - duplicate PP-100 should be deduped + - PP-42 older ticket + - QQ-9 (different project, must be ignored) + expected: jira-release-sync/tests/cases/with_keys.expected.json + - name: no_keys + release_body: | + ## What's Changed + - just some bullet points + - nothing referencing tickets + expected: jira-release-sync/tests/cases/no_keys.expected.json + - name: android_log + release_body: | + Changes since the last release: + + Allow clicking anywhere on the page in Book Details to hide the bottom drawer. + Upgrade pdfjs 2.14.305 -> 5.6.205. (Ticket: #PP-4057) + Add return confirmation. (Ticket: #PP-4126) + Reorganize book details page metadata. (Ticket: #PP-4047) + expected: jira-release-sync/tests/cases/android_log.expected.json + - name: body_via_file + release_body_file_content: | + Release notes + - PP-200 read me from a file + - PP-201 also read from a file + expected: jira-release-sync/tests/cases/body_via_file.expected.json + - name: custom_project + jira_project_key: ABC + release_body: | + ## What's Changed + - ABC-7 fix login bug + - ABC-12 add new dashboard + - PP-99 wrong project, must be ignored + expected: jira-release-sync/tests/cases/custom_project.expected.json + - name: version_create_fails + release_body: | + - PP-1 will never be reached + fail_on: POST:/rest/api/3/version + expect_action_failure: true + expected: jira-release-sync/tests/cases/version_create_fails.expected.json + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Write release body file + if: matrix.case.release_body_file_content + env: + CONTENT: ${{ matrix.case.release_body_file_content }} + run: | + mkdir -p "$RUNNER_TEMP/jira-test" + printf '%s' "$CONTENT" > "$RUNNER_TEMP/jira-test/release-body.txt" + + - name: Start mock Jira server + env: + FAIL_ON: ${{ matrix.case.fail_on }} + run: | + mkdir -p "$RUNNER_TEMP/jira-test" + fail_args=() + if [ -n "$FAIL_ON" ]; then + fail_args=(--fail-on "$FAIL_ON") + fi + python jira-release-sync/tests/mock_jira.py \ + --port 8089 \ + --log "$RUNNER_TEMP/jira-test/requests.jsonl" \ + "${fail_args[@]}" \ + > "$RUNNER_TEMP/jira-test/server.log" 2>&1 & + echo $! > "$RUNNER_TEMP/jira-test/server.pid" + for _ in $(seq 1 20); do + if curl -sf -o /dev/null "http://127.0.0.1:8089/__ready__"; then + echo "mock server ready" + exit 0 + fi + sleep 0.25 + done + echo "mock server did not become ready" >&2 + cat "$RUNNER_TEMP/jira-test/server.log" >&2 + exit 1 + + - name: Run jira-release-sync against mock + id: action + continue-on-error: ${{ matrix.case.expect_action_failure == true }} + uses: ./jira-release-sync + with: + jira-base-url: http://127.0.0.1:8089 + jira-user-email: test@example.com + jira-api-token: not-a-real-token + jira-project-key: ${{ matrix.case.jira_project_key || 'PP' }} + release-name: v1.2.3 + release-url: https://github.com/example/repo/releases/tag/v1.2.3 + release-body: ${{ matrix.case.release_body }} + release-body-file: ${{ matrix.case.release_body_file_content && format('{0}/jira-test/release-body.txt', runner.temp) || '' }} + + - name: Verify action outcome + env: + EXPECTED: ${{ (matrix.case.expect_action_failure == true) && 'failure' || 'success' }} + ACTUAL: ${{ steps.action.outcome }} + run: | + if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "Expected action outcome=$EXPECTED, got=$ACTUAL" >&2 + exit 1 + fi + + - name: Assert recorded requests + run: | + python jira-release-sync/tests/assert_calls.py \ + --log "$RUNNER_TEMP/jira-test/requests.jsonl" \ + --expected "${{ matrix.case.expected }}" + + - name: Stop mock Jira server + if: always() + run: | + if [ -f "$RUNNER_TEMP/jira-test/server.pid" ]; then + kill "$(cat "$RUNNER_TEMP/jira-test/server.pid")" || true + fi + + - name: Upload server log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: mock-jira-log-${{ matrix.case.name }} + path: ${{ runner.temp }}/jira-test/ diff --git a/jira-release-sync/tests/assert_calls.py b/jira-release-sync/tests/assert_calls.py new file mode 100644 index 0000000..aa4c569 --- /dev/null +++ b/jira-release-sync/tests/assert_calls.py @@ -0,0 +1,63 @@ +"""Assert the mock-jira request log matches expectations for a test case. + +Reads a JSONL log produced by mock_jira.py and a JSON expectations file, then +exits non-zero with a diff-style message if they don't match. We compare a +normalized projection of each request (method, path, selected body fields) so +that incidental fields like description text don't make the test brittle. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +def load_jsonl(path: Path) -> list[dict]: + return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] + + +def project(entry: dict) -> dict: + """Extract just the fields we care about asserting on.""" + method = entry["method"] + path = entry["path"] + body = entry.get("body") or {} + out: dict = {"method": method, "path": path} + + if method == "POST" and path == "/rest/api/3/version": + out["name"] = body.get("name") + out["project"] = body.get("project") + out["released"] = body.get("released") + elif method == "POST" and path.endswith("/relatedwork"): + out["title"] = body.get("title") + out["url"] = body.get("url") + out["category"] = body.get("category") + elif method == "PUT" and path.startswith("/rest/api/3/issue/"): + out["fixVersions"] = body.get("update", {}).get("fixVersions") + return out + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--log", type=Path, required=True) + parser.add_argument("--expected", type=Path, required=True) + args = parser.parse_args() + + actual = [project(e) for e in load_jsonl(args.log)] + expected = json.loads(args.expected.read_text()) + + if actual == expected: + print(f"OK: {len(actual)} requests matched expectations") + return 0 + + print("MISMATCH between actual and expected requests", file=sys.stderr) + print("--- expected ---", file=sys.stderr) + print(json.dumps(expected, indent=2), file=sys.stderr) + print("--- actual ---", file=sys.stderr) + print(json.dumps(actual, indent=2), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/jira-release-sync/tests/cases/android_log.expected.json b/jira-release-sync/tests/cases/android_log.expected.json new file mode 100644 index 0000000..e6c53da --- /dev/null +++ b/jira-release-sync/tests/cases/android_log.expected.json @@ -0,0 +1,35 @@ +[ + { + "method": "POST", + "path": "/rest/api/3/version", + "name": "v1.2.3", + "project": "PP", + "released": false + }, + { + "method": "GET", + "path": "/rest/api/3/project/PP/versions" + }, + { + "method": "POST", + "path": "/rest/api/3/version/10001/relatedwork", + "title": "GitHub Release Notes", + "url": "https://github.com/example/repo/releases/tag/v1.2.3", + "category": "release notes" + }, + { + "method": "PUT", + "path": "/rest/api/3/issue/PP-4047", + "fixVersions": [{"add": {"name": "v1.2.3"}}] + }, + { + "method": "PUT", + "path": "/rest/api/3/issue/PP-4057", + "fixVersions": [{"add": {"name": "v1.2.3"}}] + }, + { + "method": "PUT", + "path": "/rest/api/3/issue/PP-4126", + "fixVersions": [{"add": {"name": "v1.2.3"}}] + } +] diff --git a/jira-release-sync/tests/cases/body_via_file.expected.json b/jira-release-sync/tests/cases/body_via_file.expected.json new file mode 100644 index 0000000..edbc64b --- /dev/null +++ b/jira-release-sync/tests/cases/body_via_file.expected.json @@ -0,0 +1,30 @@ +[ + { + "method": "POST", + "path": "/rest/api/3/version", + "name": "v1.2.3", + "project": "PP", + "released": false + }, + { + "method": "GET", + "path": "/rest/api/3/project/PP/versions" + }, + { + "method": "POST", + "path": "/rest/api/3/version/10001/relatedwork", + "title": "GitHub Release Notes", + "url": "https://github.com/example/repo/releases/tag/v1.2.3", + "category": "release notes" + }, + { + "method": "PUT", + "path": "/rest/api/3/issue/PP-200", + "fixVersions": [{"add": {"name": "v1.2.3"}}] + }, + { + "method": "PUT", + "path": "/rest/api/3/issue/PP-201", + "fixVersions": [{"add": {"name": "v1.2.3"}}] + } +] diff --git a/jira-release-sync/tests/cases/custom_project.expected.json b/jira-release-sync/tests/cases/custom_project.expected.json new file mode 100644 index 0000000..b68cd9c --- /dev/null +++ b/jira-release-sync/tests/cases/custom_project.expected.json @@ -0,0 +1,30 @@ +[ + { + "method": "POST", + "path": "/rest/api/3/version", + "name": "v1.2.3", + "project": "ABC", + "released": false + }, + { + "method": "GET", + "path": "/rest/api/3/project/ABC/versions" + }, + { + "method": "POST", + "path": "/rest/api/3/version/10001/relatedwork", + "title": "GitHub Release Notes", + "url": "https://github.com/example/repo/releases/tag/v1.2.3", + "category": "release notes" + }, + { + "method": "PUT", + "path": "/rest/api/3/issue/ABC-12", + "fixVersions": [{"add": {"name": "v1.2.3"}}] + }, + { + "method": "PUT", + "path": "/rest/api/3/issue/ABC-7", + "fixVersions": [{"add": {"name": "v1.2.3"}}] + } +] diff --git a/jira-release-sync/tests/cases/no_keys.expected.json b/jira-release-sync/tests/cases/no_keys.expected.json new file mode 100644 index 0000000..514cd6f --- /dev/null +++ b/jira-release-sync/tests/cases/no_keys.expected.json @@ -0,0 +1,20 @@ +[ + { + "method": "POST", + "path": "/rest/api/3/version", + "name": "v1.2.3", + "project": "PP", + "released": false + }, + { + "method": "GET", + "path": "/rest/api/3/project/PP/versions" + }, + { + "method": "POST", + "path": "/rest/api/3/version/10001/relatedwork", + "title": "GitHub Release Notes", + "url": "https://github.com/example/repo/releases/tag/v1.2.3", + "category": "release notes" + } +] diff --git a/jira-release-sync/tests/cases/version_create_fails.expected.json b/jira-release-sync/tests/cases/version_create_fails.expected.json new file mode 100644 index 0000000..7ff8d24 --- /dev/null +++ b/jira-release-sync/tests/cases/version_create_fails.expected.json @@ -0,0 +1,9 @@ +[ + { + "method": "POST", + "path": "/rest/api/3/version", + "name": "v1.2.3", + "project": "PP", + "released": false + } +] diff --git a/jira-release-sync/tests/cases/with_keys.expected.json b/jira-release-sync/tests/cases/with_keys.expected.json new file mode 100644 index 0000000..5cca558 --- /dev/null +++ b/jira-release-sync/tests/cases/with_keys.expected.json @@ -0,0 +1,35 @@ +[ + { + "method": "POST", + "path": "/rest/api/3/version", + "name": "v1.2.3", + "project": "PP", + "released": false + }, + { + "method": "GET", + "path": "/rest/api/3/project/PP/versions" + }, + { + "method": "POST", + "path": "/rest/api/3/version/10001/relatedwork", + "title": "GitHub Release Notes", + "url": "https://github.com/example/repo/releases/tag/v1.2.3", + "category": "release notes" + }, + { + "method": "PUT", + "path": "/rest/api/3/issue/PP-100", + "fixVersions": [{"add": {"name": "v1.2.3"}}] + }, + { + "method": "PUT", + "path": "/rest/api/3/issue/PP-101", + "fixVersions": [{"add": {"name": "v1.2.3"}}] + }, + { + "method": "PUT", + "path": "/rest/api/3/issue/PP-42", + "fixVersions": [{"add": {"name": "v1.2.3"}}] + } +] diff --git a/jira-release-sync/tests/mock_jira.py b/jira-release-sync/tests/mock_jira.py new file mode 100644 index 0000000..9b42757 --- /dev/null +++ b/jira-release-sync/tests/mock_jira.py @@ -0,0 +1,140 @@ +"""Mock Jira REST API server for testing the jira-release-sync action. + +Records every request to a JSON log file so the test workflow can assert that +the action made the expected calls with the expected payloads. + +Implements just enough of the Jira API surface used by action.yml: + POST /rest/api/3/version create version + GET /rest/api/3/project/{key}/versions list versions (returns the one we created) + POST /rest/api/3/version/{id}/relatedwork link related work + PUT /rest/api/3/issue/{key} update issue (fixVersions) +""" + +from __future__ import annotations + +import argparse +import json +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from threading import Lock + +LOG_LOCK = Lock() +LOG_PATH: Path +CREATED_VERSION_ID = "10001" +FAIL_ON: set[tuple[str, str]] = set() + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, format, *args): # quiet default access log + sys.stderr.write("mock_jira: " + (format % args) + "\n") + + def _read_body(self) -> dict | None: + length = int(self.headers.get("Content-Length") or 0) + if not length: + return None + raw = self.rfile.read(length) + try: + return json.loads(raw) + except json.JSONDecodeError: + return {"_raw": raw.decode("utf-8", "replace")} + + def _record(self, method: str, body: dict | None) -> None: + entry = {"method": method, "path": self.path, "body": body} + with LOG_LOCK, LOG_PATH.open("a") as f: + f.write(json.dumps(entry) + "\n") + + def _send_json(self, status: int, payload: object) -> None: + data = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _maybe_fail(self, method: str) -> bool: + if (method, self.path) in FAIL_ON: + self._send_json(500, {"error": "injected failure"}) + return True + return False + + def do_GET(self) -> None: + if self.path == "/__ready__": + self._send_json(200, {"ok": True}) + return + self._record("GET", None) + if self._maybe_fail("GET"): + return + if "/rest/api/3/project/" in self.path and self.path.endswith("/versions"): + self._send_json(200, [{"id": CREATED_VERSION_ID, "name": _last_created_name()}]) + return + self._send_json(404, {"error": "not found"}) + + def do_POST(self) -> None: + body = self._read_body() + self._record("POST", body) + if self._maybe_fail("POST"): + return + if self.path == "/rest/api/3/version": + _set_last_created_name((body or {}).get("name", "")) + self._send_json(201, {"id": CREATED_VERSION_ID, "name": (body or {}).get("name")}) + return + if self.path.startswith("/rest/api/3/version/") and self.path.endswith("/relatedwork"): + self._send_json(201, {"id": "rw-1"}) + return + self._send_json(404, {"error": "not found"}) + + def do_PUT(self) -> None: + body = self._read_body() + self._record("PUT", body) + if self._maybe_fail("PUT"): + return + if self.path.startswith("/rest/api/3/issue/"): + self.send_response(204) + self.end_headers() + return + self._send_json(404, {"error": "not found"}) + + +_LAST_CREATED_NAME = {"value": ""} + + +def _set_last_created_name(name: str) -> None: + _LAST_CREATED_NAME["value"] = name + + +def _last_created_name() -> str: + return _LAST_CREATED_NAME["value"] + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--log", type=Path, required=True) + parser.add_argument( + "--fail-on", + action="append", + default=[], + metavar="METHOD:PATH", + help="Return 500 for matching requests, e.g. 'POST:/rest/api/3/version'.", + ) + args = parser.parse_args() + + global LOG_PATH + LOG_PATH = args.log + LOG_PATH.write_text("") + + for spec in args.fail_on: + method, _, path = spec.partition(":") + if not method or not path: + sys.stderr.write(f"mock_jira: invalid --fail-on spec: {spec!r}\n") + sys.exit(2) + FAIL_ON.add((method, path)) + + server = HTTPServer(("127.0.0.1", args.port), Handler) + sys.stderr.write(f"mock_jira: listening on 127.0.0.1:{args.port}, log={LOG_PATH}\n") + server.serve_forever() + + +if __name__ == "__main__": + main()