Stabilise the metadata sync workflow and add offline validation
Restructure the script embedded in the scoped sync workflow into testable functions, fix the invalid `exit 0` (now a successful run with a visible warning when Gitea returns an invalid push mirror list) and report errors as `ERROR: <message>` with exit code 1. Zero, several or malformed GitHub mirrors still fail. Add pytest-based offline tests (workflow YAML, embedded Python, credential and mirror parsing, sync flow against a fake API) that run in a virtual environment, and a validation workflow that runs them on pull requests and pushes to main. Remove the manual tests/test_.ps1 probe and the duplicate src/ copy of the workflow. Update the README and record the decisions for tasks 1 and 3 in MIL-001. Task: MIL-001#1 Task: MIL-001#2 Task: MIL-001#3 Task: MIL-001#4 Task: MIL-001#5 Task: MIL-001#6 Refs #1 Refs #2 Refs #3 Refs #4 Refs #5 Refs #6 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
pytest>=8,<10
|
||||
PyYAML>=6,<7
|
||||
@@ -1,52 +0,0 @@
|
||||
# Tests Gitea API access and checks for GitHub mirrors on a repository
|
||||
$tokenLine = Get-Content .env |
|
||||
Where-Object { $_ -match '^\s*GITEA_TOKEN\s*=' } |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $tokenLine) { throw "GITEA_TOKEN was not found in .env" }
|
||||
|
||||
$env:GITEA_TOKEN = ($tokenLine -replace '^\s*GITEA_TOKEN\s*=\s*', '').Trim().Trim('"').Trim("'")
|
||||
$api = "https://git.tirsystem.com/api/v1"
|
||||
$repo = "Tirsvad-Udemy-100_days_of_code/001-band_name_generator"
|
||||
$headers = @(
|
||||
"Authorization: token $env:GITEA_TOKEN",
|
||||
"Accept: application/json"
|
||||
)
|
||||
|
||||
foreach ($path in @("/repos/$repo", "/repos/$repo/push_mirrors")) {
|
||||
$raw = (& curl.exe --silent --show-error --max-time 20 `
|
||||
--write-out "`n__HTTP_STATUS__%{http_code}" `
|
||||
--header $headers[0] --header $headers[1] "$api$path" | Out-String)
|
||||
|
||||
$marker = "__HTTP_STATUS__"
|
||||
$index = $raw.LastIndexOf($marker)
|
||||
if ($index -lt 0) {
|
||||
Write-Output "No HTTP response for $path. curl exit code: $LASTEXITCODE"
|
||||
continue
|
||||
}
|
||||
|
||||
$body = $raw.Substring(0, $index).TrimEnd("`r", "`n")
|
||||
$status = $raw.Substring($index + $marker.Length).Trim()
|
||||
Write-Output "$path -> HTTP $status"
|
||||
|
||||
if ($status -eq "200" -and $path.EndsWith("/push_mirrors")) {
|
||||
$body | ConvertFrom-Json | ForEach-Object {
|
||||
$match = [regex]::Match(
|
||||
[string]$_.remote_address,
|
||||
'github\.com[:/](?<target>[^?#]+)'
|
||||
)
|
||||
if ($match.Success) {
|
||||
Write-Output ("GitHub mirror: github.com/{0}" -f ($match.Groups["target"].Value -replace '\.git$', ''))
|
||||
} else {
|
||||
Write-Output "Mirror found; GitHub target could not be identified."
|
||||
}
|
||||
}
|
||||
} elseif ($status -ne "200") {
|
||||
try {
|
||||
$errorBody = $body | ConvertFrom-Json
|
||||
if ($errorBody.message) { Write-Output $errorBody.message }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Item Env:\GITEA_TOKEN
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Offline tests for the Python embedded in sync-github-metadata.yml."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import urllib.error
|
||||
|
||||
import pytest
|
||||
|
||||
from workflow_source import load_sync_module
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sync():
|
||||
return load_sync_module()
|
||||
|
||||
|
||||
class TestGithubToken:
|
||||
def test_returns_stripped_token_from_json_object(self, sync):
|
||||
raw = json.dumps({"GITHUB_PAT": " ghp_abc "})
|
||||
assert sync["parse_github_token"](raw) == "ghp_abc"
|
||||
|
||||
@pytest.mark.parametrize("raw", [None, "", " "])
|
||||
def test_rejects_missing_or_empty_value(self, sync, raw):
|
||||
with pytest.raises(RuntimeError, match="missing or empty"):
|
||||
sync["parse_github_token"](raw)
|
||||
|
||||
def test_rejects_invalid_json(self, sync):
|
||||
with pytest.raises(RuntimeError, match="valid JSON"):
|
||||
sync["parse_github_token"]("ghp_plain_token")
|
||||
|
||||
def test_rejects_json_that_is_not_an_object(self, sync):
|
||||
with pytest.raises(RuntimeError, match="JSON object"):
|
||||
sync["parse_github_token"]('["x"]')
|
||||
|
||||
@pytest.mark.parametrize("value", [{}, {"GITHUB_PAT": ""}, {"GITHUB_PAT": 5}])
|
||||
def test_rejects_missing_or_invalid_pat(self, sync, value):
|
||||
with pytest.raises(RuntimeError, match="GITHUB_PAT"):
|
||||
sync["parse_github_token"](json.dumps(value))
|
||||
|
||||
|
||||
class TestGiteaToken:
|
||||
def test_accepts_bare_token(self, sync):
|
||||
assert sync["parse_gitea_token"]("abc123") == "abc123"
|
||||
|
||||
def test_accepts_json_object(self, sync):
|
||||
assert sync["parse_gitea_token"]('{"GITEA_TOKEN": " t "}') == "t"
|
||||
|
||||
def test_accepts_json_string(self, sync):
|
||||
assert sync["parse_gitea_token"]('"quoted"') == "quoted"
|
||||
|
||||
@pytest.mark.parametrize("raw", [None, "", " "])
|
||||
def test_rejects_missing_or_empty_value(self, sync, raw):
|
||||
with pytest.raises(RuntimeError, match="missing or empty"):
|
||||
sync["parse_gitea_token"](raw)
|
||||
|
||||
@pytest.mark.parametrize("raw", ["{}", '{"GITEA_TOKEN": ""}', "12", "[]"])
|
||||
def test_rejects_value_without_token(self, sync, raw):
|
||||
with pytest.raises(RuntimeError, match="GITEA_TOKEN"):
|
||||
sync["parse_gitea_token"](raw)
|
||||
|
||||
|
||||
class TestSplitRepository:
|
||||
def test_splits_owner_and_repo(self, sync):
|
||||
assert sync["split_repository"]("TirSystem/github-action") == (
|
||||
"TirSystem",
|
||||
"github-action",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("value", [None, "", "owner", "/repo", "owner/"])
|
||||
def test_rejects_incomplete_names(self, sync, value):
|
||||
with pytest.raises(RuntimeError, match="source repository"):
|
||||
sync["split_repository"](value)
|
||||
|
||||
|
||||
class TestFindGithubTargets:
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
[
|
||||
"git@github.com:Org/repo.git",
|
||||
"https://github.com/Org/repo.git",
|
||||
"https://user:token@github.com/Org/repo",
|
||||
"ssh://git@github.com/Org/repo.git",
|
||||
],
|
||||
)
|
||||
def test_extracts_owner_and_repo(self, sync, address):
|
||||
mirrors = [{"remote_address": address}]
|
||||
assert sync["find_github_targets"](mirrors) == ("Org", "repo")
|
||||
|
||||
def test_ignores_non_github_mirrors(self, sync):
|
||||
mirrors = [
|
||||
{"remote_address": "https://gitlab.com/Org/other.git"},
|
||||
{"remote_address": "https://github.com/Org/repo.git"},
|
||||
]
|
||||
assert sync["find_github_targets"](mirrors) == ("Org", "repo")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mirrors",
|
||||
[
|
||||
[],
|
||||
[{"remote_address": "https://gitlab.com/Org/other.git"}],
|
||||
],
|
||||
)
|
||||
def test_fails_when_no_github_mirror_exists(self, sync, mirrors):
|
||||
with pytest.raises(RuntimeError, match="found 0"):
|
||||
sync["find_github_targets"](mirrors)
|
||||
|
||||
def test_fails_when_several_github_mirrors_exist(self, sync):
|
||||
mirrors = [
|
||||
{"remote_address": "https://github.com/Org/one.git"},
|
||||
{"remote_address": "https://github.com/Org/two.git"},
|
||||
]
|
||||
with pytest.raises(RuntimeError, match="found 2"):
|
||||
sync["find_github_targets"](mirrors)
|
||||
|
||||
def test_fails_when_path_has_wrong_shape(self, sync):
|
||||
mirrors = [{"remote_address": "https://github.com/only-owner"}]
|
||||
with pytest.raises(RuntimeError, match="owner and repository"):
|
||||
sync["find_github_targets"](mirrors)
|
||||
|
||||
|
||||
class TestRequestJson:
|
||||
def test_maps_http_error_without_leaking_the_response_body(self, sync, monkeypatch):
|
||||
def fake_urlopen(request, timeout):
|
||||
raise urllib.error.HTTPError(
|
||||
request.full_url, 403, "Forbidden", {}, io.BytesIO(b"secret detail")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(sync["urllib"].request, "urlopen", fake_urlopen)
|
||||
with pytest.raises(RuntimeError) as caught:
|
||||
sync["request_json"]("https://example.test/x")
|
||||
assert str(caught.value) == "API request failed with HTTP 403 (Forbidden)"
|
||||
|
||||
|
||||
class FakeApi:
|
||||
"""Records calls and answers like the Gitea and GitHub REST APIs."""
|
||||
|
||||
def __init__(self, mirrors, source=None):
|
||||
self.mirrors = mirrors
|
||||
self.source = source or {"description": "Desc", "topics": ["a", "b"]}
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, url, method="GET", headers=None, body=None):
|
||||
self.calls.append((method, url, headers, body))
|
||||
if url.endswith("/push_mirrors"):
|
||||
return self.mirrors
|
||||
if url.startswith("https://git.example.test"):
|
||||
return self.source
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def environment(monkeypatch):
|
||||
monkeypatch.setenv("GITEA_API_URL", "https://git.example.test/api/v1/")
|
||||
monkeypatch.setenv("GITEA_CREDENTIALS", "gitea-token")
|
||||
monkeypatch.setenv("SOURCE_REPOSITORY", "TirSystem/repo")
|
||||
monkeypatch.setenv("GITHUB_CREDENTIALS", json.dumps({"GITHUB_PAT": "gh-token"}))
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_syncs_description_and_topics_to_the_single_github_mirror(
|
||||
self, sync, environment, capsys
|
||||
):
|
||||
api = FakeApi([{"remote_address": "git@github.com:Org/repo.git"}])
|
||||
sync["request_json"] = api
|
||||
|
||||
assert sync["main"]() == 0
|
||||
|
||||
requests = [(call[0], call[1]) for call in api.calls]
|
||||
assert requests == [
|
||||
("GET", "https://git.example.test/api/v1/repos/TirSystem/repo"),
|
||||
("GET", "https://git.example.test/api/v1/repos/TirSystem/repo/push_mirrors"),
|
||||
("PATCH", "https://api.github.com/repos/Org/repo"),
|
||||
("PUT", "https://api.github.com/repos/Org/repo/topics"),
|
||||
]
|
||||
assert api.calls[0][2]["Authorization"] == "token gitea-token"
|
||||
assert api.calls[2][2]["Authorization"] == "Bearer gh-token"
|
||||
assert api.calls[2][3] == {"description": "Desc"}
|
||||
assert api.calls[3][3] == {"names": ["a", "b"]}
|
||||
assert "Synced description and topics to Org/repo." in capsys.readouterr().out
|
||||
|
||||
def test_sends_empty_values_when_gitea_has_none(self, sync, environment):
|
||||
api = FakeApi(
|
||||
[{"remote_address": "git@github.com:Org/repo.git"}],
|
||||
source={"description": None, "topics": None},
|
||||
)
|
||||
sync["request_json"] = api
|
||||
|
||||
sync["main"]()
|
||||
|
||||
assert api.calls[2][3] == {"description": ""}
|
||||
assert api.calls[3][3] == {"names": []}
|
||||
|
||||
def test_skips_with_a_warning_when_mirror_list_is_invalid(
|
||||
self, sync, environment, capsys
|
||||
):
|
||||
api = FakeApi({"message": "unexpected"})
|
||||
sync["request_json"] = api
|
||||
|
||||
assert sync["main"]() == 0
|
||||
|
||||
assert all(call[0] == "GET" for call in api.calls)
|
||||
assert "WARNING" in capsys.readouterr().out
|
||||
|
||||
def test_fails_before_any_request_when_credentials_are_missing(
|
||||
self, sync, environment, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("GITHUB_CREDENTIALS", "")
|
||||
api = FakeApi([])
|
||||
sync["request_json"] = api
|
||||
|
||||
with pytest.raises(RuntimeError, match="CREDENTIALS_FOR_GITHUB"):
|
||||
sync["main"]()
|
||||
assert api.calls == []
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Static validation of every workflow file in this repository."""
|
||||
|
||||
import ast
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from workflow_source import REPO_ROOT, SYNC_WORKFLOW, embedded_python, workflow_files
|
||||
|
||||
WORKFLOWS = workflow_files()
|
||||
|
||||
|
||||
def test_workflow_files_exist():
|
||||
assert WORKFLOWS, "no workflow files found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name)
|
||||
class TestEachWorkflow:
|
||||
def test_is_valid_yaml_with_name_and_jobs(self, path):
|
||||
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
assert isinstance(document, dict)
|
||||
assert document.get("name"), "a workflow needs a name"
|
||||
assert document.get("jobs"), "a workflow needs at least one job"
|
||||
|
||||
def test_embedded_python_compiles(self, path):
|
||||
for source in embedded_python(path):
|
||||
ast.parse(source, filename=str(path))
|
||||
|
||||
def test_every_job_declares_permissions_and_runner(self, path):
|
||||
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
for name, job in document["jobs"].items():
|
||||
assert "runs-on" in job, f"job {name} has no runs-on"
|
||||
assert "permissions" in job, f"job {name} has no explicit permissions"
|
||||
|
||||
|
||||
def test_sync_workflow_embeds_exactly_one_script():
|
||||
assert len(embedded_python(SYNC_WORKFLOW)) == 1
|
||||
|
||||
|
||||
def test_each_workflow_name_has_one_source():
|
||||
names = [path.name for path in WORKFLOWS]
|
||||
assert len(names) == len(set(names)), "a workflow exists in several directories"
|
||||
|
||||
|
||||
def test_no_workflow_copy_outside_the_workflow_directories():
|
||||
stray = [
|
||||
path.relative_to(REPO_ROOT)
|
||||
for pattern in ("*.yml", "*.yaml")
|
||||
for folder in (REPO_ROOT, REPO_ROOT / "src")
|
||||
if folder.is_dir()
|
||||
for path in folder.glob(pattern)
|
||||
if path.name == SYNC_WORKFLOW.name
|
||||
]
|
||||
assert not stray, f"duplicate workflow copies: {stray}"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Helpers that read workflow files and the Python embedded in them."""
|
||||
|
||||
import re
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
WORKFLOW_DIRS = (
|
||||
REPO_ROOT / ".gitea" / "scoped_workflows",
|
||||
REPO_ROOT / ".gitea" / "workflows",
|
||||
)
|
||||
SYNC_WORKFLOW = WORKFLOW_DIRS[0] / "sync-github-metadata.yml"
|
||||
|
||||
_HEREDOC = re.compile(
|
||||
r"^[ \t]*python3 - <<'PY'\r?\n(?P<code>.*?)^[ \t]*PY[ \t]*\r?$",
|
||||
re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def workflow_files() -> list[Path]:
|
||||
"""Return every workflow file in the known workflow directories."""
|
||||
files: list[Path] = []
|
||||
for directory in WORKFLOW_DIRS:
|
||||
if directory.is_dir():
|
||||
files += sorted(directory.glob("*.yml")) + sorted(directory.glob("*.yaml"))
|
||||
return files
|
||||
|
||||
|
||||
def embedded_python(path: Path) -> list[str]:
|
||||
"""Return the source of each `python3 - <<'PY'` heredoc in a workflow."""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
return [
|
||||
textwrap.dedent(match.group("code").replace("\r\n", "\n"))
|
||||
for match in _HEREDOC.finditer(text)
|
||||
]
|
||||
|
||||
|
||||
def load_sync_module(name: str = "embedded_sync") -> dict[str, Any]:
|
||||
"""Execute the embedded sync script without running `main`."""
|
||||
(source,) = embedded_python(SYNC_WORKFLOW)
|
||||
namespace: dict[str, Any] = {"__name__": name}
|
||||
exec(compile(source, str(SYNC_WORKFLOW), "exec"), namespace)
|
||||
return namespace
|
||||
Reference in New Issue
Block a user