From 62eac8b7cfa43eaf643f994586ffb5ad073f8d88 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sat, 3 Oct 2026 18:42:07 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20TirSystem=20GitHub=20Actions=20s?= =?UTF-8?q?ync=20github=20metadata=20Add=20sync-github-metadata=20workflow?= =?UTF-8?q?=20to=20mirror=20description=20and=20topics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoped_workflows/sync-github-metadata.yml | 161 ++++++++++++++++++ README.md | 2 +- tests/test_.ps1 | 52 ++++++ 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 .gitea/scoped_workflows/sync-github-metadata.yml create mode 100644 tests/test_.ps1 diff --git a/.gitea/scoped_workflows/sync-github-metadata.yml b/.gitea/scoped_workflows/sync-github-metadata.yml new file mode 100644 index 0000000..9b19961 --- /dev/null +++ b/.gitea/scoped_workflows/sync-github-metadata.yml @@ -0,0 +1,161 @@ +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): + raise RuntimeError("Gitea returned an invalid push mirror list.") + + 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 diff --git a/README.md b/README.md index c4e1adc..8dfc5f1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🚀 TirSystem GitHub Actions +# 🚀 TirSystem GitHub Actions sync github metadata Global, reusable GitHub Actions and workflows for TirSystem projects. diff --git a/tests/test_.ps1 b/tests/test_.ps1 new file mode 100644 index 0000000..d911835 --- /dev/null +++ b/tests/test_.ps1 @@ -0,0 +1,52 @@ +# 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[:/](?[^?#]+)' + ) + 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 \ No newline at end of file