Adopt the SQA/QC framework as a submodule and plan the project before any further code: Business Case, Stakeholder Analysis (S01-S05), Project Plan with use case candidates and prioritisation, and milestones MIL-001..MIL-004 (24 tasks, synced to Gitea as milestones 18-21 and issues #1-#24). README now separates implemented from planned capabilities, has an English sequence diagram and points at the scoped workflow. The workflow file moves out of the repository root and out of .gitea/workflows/; the scoped copy is unchanged in behaviour here and still contains the known `exit 0` syntax error, which MIL-001 fixes. Refs #1 Refs #2 Refs #3 Refs #6 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
163 lines
6.2 KiB
YAML
163 lines
6.2 KiB
YAML
name: Sync GitHub mirror metadata
|
|
|
|
on:
|
|
push:
|
|
branches: [ main ]
|
|
workflow_dispatch:
|
|
schedule:
|
|
- cron: "17 3 * * *"
|
|
|
|
jobs:
|
|
sync-metadata:
|
|
permissions:
|
|
contents: read
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Sync description and topics
|
|
env:
|
|
GITEA_API_URL: ${{ gitea.api_url }}
|
|
GITEA_CREDENTIALS: ${{ secrets.TOKEN_FOR_GITEA }}
|
|
SOURCE_REPOSITORY: ${{ gitea.repository }}
|
|
GITHUB_CREDENTIALS: ${{ secrets.CREDENTIALS_FOR_GITHUB }}
|
|
run: |
|
|
python3 - <<'PY'
|
|
import json
|
|
import os
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
def request_json(url, method="GET", headers=None, body=None):
|
|
request = urllib.request.Request(
|
|
url,
|
|
data=json.dumps(body).encode("utf-8") if body is not None else None,
|
|
headers=headers or {},
|
|
method=method,
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
content = response.read()
|
|
return json.loads(content) if content else None
|
|
except urllib.error.HTTPError as error:
|
|
raise RuntimeError(
|
|
f"API request failed with HTTP {error.code} ({error.reason})"
|
|
) from None
|
|
|
|
raw_credentials = os.environ.get("GITHUB_CREDENTIALS", "").strip()
|
|
if not raw_credentials:
|
|
raise RuntimeError("CREDENTIALS_FOR_GITHUB is missing or empty.")
|
|
|
|
try:
|
|
credentials = json.loads(raw_credentials)
|
|
except json.JSONDecodeError:
|
|
raise RuntimeError(
|
|
"CREDENTIALS_FOR_GITHUB must contain valid JSON."
|
|
) from None
|
|
|
|
if not isinstance(credentials, dict):
|
|
raise RuntimeError(
|
|
"CREDENTIALS_FOR_GITHUB must be a JSON object."
|
|
)
|
|
|
|
github_token = credentials.get("GITHUB_PAT")
|
|
if not isinstance(github_token, str) or not github_token.strip():
|
|
raise RuntimeError(
|
|
"CREDENTIALS_FOR_GITHUB must contain a non-empty GITHUB_PAT."
|
|
)
|
|
|
|
raw_gitea_credentials = os.environ.get("GITEA_CREDENTIALS", "").strip()
|
|
if not raw_gitea_credentials:
|
|
raise RuntimeError("TOKEN_FOR_GITEA is missing or empty.")
|
|
|
|
try:
|
|
gitea_credentials = json.loads(raw_gitea_credentials)
|
|
except json.JSONDecodeError:
|
|
gitea_token = raw_gitea_credentials
|
|
else:
|
|
if isinstance(gitea_credentials, dict):
|
|
gitea_token = gitea_credentials.get("GITEA_TOKEN")
|
|
elif isinstance(gitea_credentials, str):
|
|
gitea_token = gitea_credentials
|
|
else:
|
|
gitea_token = None
|
|
|
|
if not isinstance(gitea_token, str) or not gitea_token.strip():
|
|
raise RuntimeError(
|
|
"TOKEN_FOR_GITEA must contain a non-empty GITEA_TOKEN."
|
|
)
|
|
source_owner, separator, source_repo = os.environ[
|
|
"SOURCE_REPOSITORY"
|
|
].partition("/")
|
|
if not separator or not source_owner or not source_repo:
|
|
raise RuntimeError("Could not determine the Gitea source repository.")
|
|
|
|
gitea_api_url = os.environ["GITEA_API_URL"].rstrip("/")
|
|
source_url = f"{gitea_api_url}/repos/{source_owner}/{source_repo}"
|
|
gitea_headers = {
|
|
"Authorization": f"token {gitea_token.strip()}",
|
|
"Accept": "application/json",
|
|
}
|
|
source = request_json(source_url, headers=gitea_headers)
|
|
mirrors = request_json(
|
|
f"{source_url}/push_mirrors",
|
|
headers=gitea_headers,
|
|
)
|
|
if not isinstance(mirrors, list):
|
|
print("Gitea returned an invalid push mirror list.")
|
|
exit 0
|
|
|
|
github_targets = []
|
|
for mirror in mirrors:
|
|
remote_address = mirror.get("remote_address", "")
|
|
if remote_address.startswith("git@github.com:"):
|
|
mirror_path = remote_address.split(":", 1)[1]
|
|
else:
|
|
parsed_remote = urllib.parse.urlsplit(remote_address)
|
|
if parsed_remote.hostname != "github.com":
|
|
continue
|
|
mirror_path = parsed_remote.path.lstrip("/")
|
|
|
|
mirror_path = mirror_path.removesuffix(".git").strip("/")
|
|
path_parts = mirror_path.split("/")
|
|
if len(path_parts) != 2 or not all(path_parts):
|
|
raise RuntimeError(
|
|
"Could not determine the GitHub owner and repository "
|
|
"from a configured push mirror."
|
|
)
|
|
github_targets.append(tuple(path_parts))
|
|
|
|
if len(github_targets) != 1:
|
|
raise RuntimeError(
|
|
"Expected exactly one GitHub push mirror for this repository; "
|
|
f"found {len(github_targets)}."
|
|
)
|
|
|
|
github_owner, github_repo = github_targets[0]
|
|
github_api_url = (
|
|
"https://api.github.com/repos/"
|
|
f"{urllib.parse.quote(github_owner, safe='')}/"
|
|
f"{urllib.parse.quote(github_repo, safe='')}"
|
|
)
|
|
github_headers = {
|
|
"Authorization": f"Bearer {github_token.strip()}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
request_json(
|
|
github_api_url,
|
|
method="PATCH",
|
|
headers=github_headers,
|
|
body={"description": source.get("description") or ""},
|
|
)
|
|
request_json(
|
|
f"{github_api_url}/topics",
|
|
method="PUT",
|
|
headers=github_headers,
|
|
body={"names": source.get("topics") or []},
|
|
)
|
|
|
|
print(f"Synced description and topics to {github_owner}/{github_repo}.")
|
|
PY
|