Compare commits

..
3 Commits
Author SHA1 Message Date
TirsvadandClaude Sonnet 5.5 a1561e2c6c Resolve pending commit link in MIL-002 version history
Validate workflows / validate (pull_request) Successful in 20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 21:26:53 +08:00
TirsvadandClaude Sonnet 5.5 7dcb9c4dfc Add credential preflight, categorized errors and operations guides
Sync workflow: every failure is now reported as `ERROR: [category] message`
with the categories configuration, credentials (HTTP 401), permissions
(HTTP 403) and api (other HTTP errors, unreachable service); HTTP 404 is a
configuration error. Before changing anything the workflow checks that the
GitHub token can administer the mirror repository and stops with a
permissions error otherwise. No secret value is printed.

Guides: onboarding (with runner labels, prerequisites and offline runner
symptoms), credentials (minimum permissions, preflight, rotation) and
troubleshooting (every message mapped to a category and a fix). README
capability table updated. MIL-002 records the verification of scoped
workflow support: Gitea 1.27.3 runs the workflow in this repository;
delivery to other repositories is not yet verified.

Task: MIL-002#2
Task: MIL-002#3
Task: MIL-002#4
Task: MIL-002#5
Task: MIL-002#6
Task: MIL-002#7
Refs #9
Refs #10
Refs #11
Refs #12
Refs #13
Refs #14

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 21:26:49 +08:00
TirsvadandClaude Sonnet 5.5 16b9060d4c Mark MIL-002, MIL-003 and MIL-004 as Accepted
Set the owner/reviewer revision of each milestone to Accepted and deprecate
its initial row, as the Product Owner accepted the milestones. No task
content changed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 21:19:58 +08:00
9 changed files with 366 additions and 34 deletions
@@ -29,6 +29,25 @@ jobs:
import urllib.request
class WorkflowError(RuntimeError):
"""A failure with a category.
configuration: a secret or repository setting is missing or malformed.
credentials: a service rejected the token (HTTP 401).
permissions: the token is valid but not allowed (HTTP 403).
api: any other API or network failure.
"""
def __init__(self, category, message):
super().__init__(f"[{category}] {message}")
self.category = category
def service_name(url):
hostname = urllib.parse.urlsplit(url).hostname
return "GitHub" if hostname == "api.github.com" else "Gitea"
def request_json(url, method="GET", headers=None, body=None):
request = urllib.request.Request(
url,
@@ -36,37 +55,64 @@ jobs:
headers=headers or {},
method=method,
)
service = service_name(url)
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})"
detail = (
f"{service} API request failed with HTTP "
f"{error.code} ({error.reason})"
)
if error.code == 401:
raise WorkflowError(
"credentials", f"{detail}. The token was rejected."
) from None
if error.code == 403:
raise WorkflowError(
"permissions",
f"{detail}. The token lacks a required permission.",
) from None
if error.code == 404:
raise WorkflowError(
"configuration",
f"{detail}. The repository was not found or the "
"token cannot see it.",
) from None
raise WorkflowError("api", detail) from None
except (urllib.error.URLError, TimeoutError) as error:
raise WorkflowError(
"api", f"{service} API is unreachable ({error})."
) from None
def parse_github_token(raw_credentials):
raw_credentials = (raw_credentials or "").strip()
if not raw_credentials:
raise RuntimeError("CREDENTIALS_FOR_GITHUB is missing or empty.")
raise WorkflowError(
"configuration", "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."
raise WorkflowError(
"configuration",
"CREDENTIALS_FOR_GITHUB must contain valid JSON.",
) from None
if not isinstance(credentials, dict):
raise RuntimeError(
"CREDENTIALS_FOR_GITHUB must be a JSON object."
raise WorkflowError(
"configuration",
"CREDENTIALS_FOR_GITHUB must be a JSON object.",
)
token = credentials.get("GITHUB_PAT")
if not isinstance(token, str) or not token.strip():
raise RuntimeError(
"CREDENTIALS_FOR_GITHUB must contain a non-empty GITHUB_PAT."
raise WorkflowError(
"configuration",
"CREDENTIALS_FOR_GITHUB must contain a non-empty GITHUB_PAT.",
)
return token.strip()
@@ -74,7 +120,9 @@ jobs:
def parse_gitea_token(raw_credentials):
raw_credentials = (raw_credentials or "").strip()
if not raw_credentials:
raise RuntimeError("TOKEN_FOR_GITEA is missing or empty.")
raise WorkflowError(
"configuration", "TOKEN_FOR_GITEA is missing or empty."
)
try:
credentials = json.loads(raw_credentials)
@@ -89,8 +137,9 @@ jobs:
token = None
if not isinstance(token, str) or not token.strip():
raise RuntimeError(
"TOKEN_FOR_GITEA must contain a non-empty GITEA_TOKEN."
raise WorkflowError(
"configuration",
"TOKEN_FOR_GITEA must contain a non-empty GITEA_TOKEN.",
)
return token.strip()
@@ -98,7 +147,10 @@ jobs:
def split_repository(full_name):
owner, separator, repo = (full_name or "").partition("/")
if not separator or not owner or not repo:
raise RuntimeError("Could not determine the Gitea source repository.")
raise WorkflowError(
"configuration",
"Could not determine the Gitea source repository.",
)
return owner, repo
@@ -117,16 +169,18 @@ jobs:
mirror_path = mirror_path.removesuffix(".git").strip("/")
path_parts = mirror_path.split("/")
if len(path_parts) != 2 or not all(path_parts):
raise RuntimeError(
raise WorkflowError(
"configuration",
"Could not determine the GitHub owner and repository "
"from a configured push mirror."
"from a configured push mirror.",
)
targets.append(tuple(path_parts))
if len(targets) != 1:
raise RuntimeError(
raise WorkflowError(
"configuration",
"Expected exactly one GitHub push mirror for this repository; "
f"found {len(targets)}."
f"found {len(targets)}.",
)
return targets[0]
@@ -169,6 +223,16 @@ jobs:
"Content-Type": "application/json",
}
github_repository = request_json(github_api_url, headers=github_headers)
permissions = (github_repository or {}).get("permissions")
if isinstance(permissions, dict) and not permissions.get("admin"):
raise WorkflowError(
"permissions",
f"The GitHub token cannot administer {github_owner}/{github_repo}; "
"editing the description and topics needs administration access.",
)
print("Preflight passed: Gitea and GitHub accepted the credentials.")
request_json(
github_api_url,
method="PATCH",
+2 -2
View File
@@ -63,8 +63,8 @@ Only the first row is implemented. The rest is planned and tracked in the [Proje
| Capability | Status |
| --- | --- |
| Synchronize repository metadata (Gitea to GitHub mirror) | Implemented in `.gitea/scoped_workflows/`, with offline tests and a validation workflow |
| Configure and validate workflow credentials | Partial: secrets are checked for format only |
| Onboard a repository, manage runners, diagnose failures | Planned (MIL-002) |
| Configure and validate workflow credentials | Implemented for the sync workflow: preflight checks both tokens before any change ([guide](guides/credentials.md)) |
| Onboard a repository, manage runners, diagnose failures | Guides written ([onboarding](guides/onboarding.md), [troubleshooting](guides/troubleshooting.md)); scoped delivery to other repositories not yet verified; brief use cases open (MIL-002) |
| Shared quality checks | Planned (MIL-003) |
| Versioned release and publishing | Planned (MIL-004) |
@@ -9,8 +9,8 @@
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] |
| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] |
| 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] |
| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Recorded verification results for task 2 | [7dcb9c4] |
---
@@ -63,7 +63,7 @@ a credential validation step in the workflow, and categorized failure messages.
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
| --- | --- | --- | --- | --- |
| 1 | Write brief use cases for the P1 candidates | Turn the DevOps use cases in the Project Plan appendix into use case documents under `docs/uc-NNN/`, after a use case diagram and user stories exist. | No | |
| 2 | Verify scoped workflow support on the instance | The Business Case lists this as an unverified assumption. Check the Gitea version and how a scoped workflow reaches a repository, then record the result. | No | |
| 2 | Verify scoped workflow support on the instance | The Business Case lists this as an unverified assumption. Check the Gitea version and how a scoped workflow reaches a repository, then record the result. Result: Gitea 1.27.3 runs the scoped sync workflow in this repository (run 16); delivery to other repositories is not yet verified and is recorded in `guides/onboarding.md`. | No | |
| 3 | Write the repository onboarding steps | Describe how to consume the workflow (scoped or copied), which secrets to set and how to test the first run. | No | |
| 4 | Document least-privilege tokens | Determine the minimum Gitea token scope and GitHub fine-grained token permission that the sync needs, and document them. | No | |
| 5 | Add a credential preflight step | Before the sync, check that both tokens authenticate and have the needed access, and report the result without printing any secret. | No | |
@@ -74,3 +74,4 @@ a credential validation step in the workflow, and categorized failure messages.
[BC-001]: ../business-case.md
[4de5438]: https://git.tirsystem.com/TirSystem/github-action/commit/4de5438a88b4bbf18b165f52f797d1a52af89f6d
[7dcb9c4]: https://git.tirsystem.com/TirSystem/github-action/commit/7dcb9c4dfce712572aced281d0dfdfa435283646
+2 -2
View File
@@ -9,8 +9,8 @@
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] |
| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] |
| 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] |
| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] |
---
+2 -2
View File
@@ -9,8 +9,8 @@
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] |
| 2026-10-03 | Proposed | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] |
| 2026-10-03 | Deprecated | Jens Tirsvad Nielsen | S01 | Initial version | [4de5438] |
| 2026-10-03 | Accepted | Jens Tirsvad Nielsen | S01 | Owner is S05 (security authority); S01 remains approving reviewer | [4de5438] |
---
+63
View File
@@ -0,0 +1,63 @@
# Workflow credentials
The sync workflow uses two secrets. Give each the least privilege that works,
store it only as a Gitea secret, and rotate it when a person with access
leaves.
| Secret | Content | Used for |
| --- | --- | --- |
| `TOKEN_FOR_GITEA` | A bare token, a JSON string, or JSON with `GITEA_TOKEN` | Reading the repository (description, topics) and its push mirrors |
| `CREDENTIALS_FOR_GITHUB` | JSON object with a non-empty `GITHUB_PAT` | Changing the description and topics of the GitHub mirror |
## Minimum permissions
These come from the Gitea and GitHub documentation and the API calls the
workflow makes. They have **not been confirmed with a minimal token on this
instance yet**; S05 should confirm them when the tokens are created.
**Gitea token** (a personal access token of a user, not a deploy key; deploy
keys do not work for the REST API):
- scope `read:repository`
- the user must be allowed to read the repository's push mirrors, which
Gitea limits to repository administrators
Calls made: `GET /repos/{owner}/{repo}` and `GET /repos/{owner}/{repo}/push_mirrors`.
**GitHub token** (fine-grained personal access token):
- Resource owner: the owner of the mirror repository
- Repository access: only the mirror repository
- Repository permission: **Administration: Read and write** (no other permission)
- Set an expiry date
Calls made: `GET /repos/{owner}/{repo}`, `PATCH /repos/{owner}/{repo}` (description)
and `PUT /repos/{owner}/{repo}/topics`.
Format of `CREDENTIALS_FOR_GITHUB`:
```json
{"GITHUB_PAT": "<token>"}
```
## What the workflow checks before it changes anything
The workflow runs a preflight and writes nothing to GitHub unless it passes:
1. Both secrets exist and have the expected shape.
2. Gitea accepts the token and the repository is visible with it.
3. Exactly one GitHub push mirror is configured.
4. GitHub accepts the token for the mirror repository **and** the token
reports administration access to it.
On success the log says `Preflight passed: Gitea and GitHub accepted the
credentials.` Failures start with a category such as `[credentials]` or
`[permissions]`; see [troubleshooting.md](troubleshooting.md). No secret value
is ever printed.
## Rotating a token
1. Create the new token with the permissions above.
2. Replace the secret value in the repository or organization settings.
3. Run the workflow manually and wait for `Preflight passed`.
4. Revoke the old token.
+71
View File
@@ -0,0 +1,71 @@
# Onboarding a repository
How a DevOps professional makes a TirSystem repository use the shared
**Sync GitHub mirror metadata** workflow, and which runners it needs. Only
this workflow exists today; the quality-check and release workflows are
planned (see the [Project Plan](../docs/project-plan.md)).
## What has been verified
| Item | Status |
| --- | --- |
| Gitea version | 1.27.3 |
| `.gitea/scoped_workflows/sync-github-metadata.yml` runs in this repository | Verified: run 16, `workflow_dispatch`, succeeded |
| The same workflow applies to other repositories automatically | **Not verified.** Confirm how scoped workflows are delivered on the instance (admin or organization settings) before relying on it. |
Until delivery is verified, onboard a repository by copying the workflow (below).
## Steps
1. **Check the prerequisites** in the repository: Actions are enabled, and a
runner with the label `ubuntu-latest` and `python3` is online (see
[Runners](#runners)).
2. **Configure exactly one GitHub push mirror** in the repository settings
(Settings, Repository, Mirror settings, Push mirror). The workflow fails
when there are zero or several GitHub mirrors.
3. **Add the two secrets** in the repository (or organization) settings,
using the scopes in [credentials.md](credentials.md):
- `TOKEN_FOR_GITEA`
- `CREDENTIALS_FOR_GITHUB` (JSON with `GITHUB_PAT`)
4. **Deliver the workflow:**
- *If scoped workflows apply to the repository:* nothing to copy.
- *Otherwise:* copy `.gitea/scoped_workflows/sync-github-metadata.yml`
from this repository to `.gitea/workflows/` in the target repository.
Pin the copy to a reviewed version of this repository and re-copy
when it changes.
5. **Set a description and topics** on the Gitea repository, so there is
something to sync.
6. **Run it once manually:** Actions, **Sync GitHub mirror metadata**,
Run workflow. The log must end with `Synced description and topics to
<owner>/<repo>.` and the GitHub repository must show the same description
and topics.
7. If it fails, use [troubleshooting.md](troubleshooting.md).
## Runners
| Item | Value |
| --- | --- |
| Label the workflow uses | `ubuntu-latest` |
| Labels offered by the runner on this instance | `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04` |
| Tools the workflow needs | `python3` (standard library only), outbound HTTPS to the Gitea API and `api.github.com` |
| Needed for `actions/checkout` (used by the validation workflow) | `git` and Node.js, normally in the runner image |
**Check that a suitable runner is online** (needs permission to view runners):
```bash
curl -s -H "Authorization: token $GITEA_TOKEN" \
https://git.tirsystem.com/api/v1/repos/<owner>/<repo>/actions/runners
```
For runners shared by the whole instance, an administrator uses
`/api/v1/admin/actions/runners`. At the time of writing the repository and
organization lists are empty and one instance-level runner (`36cd20104923`)
is `online`.
**Offline or incompatible runner**
| Symptom | Likely cause | Action |
| --- | --- | --- |
| Run stays `waiting` | No online runner has the label in `runs-on` | Start the runner, or register one with the label |
| Job fails at the start with a missing command | Runner image lacks `python3`, `git` or Node.js | Use an image that has them |
| Job cannot reach the API | Runner has no outbound HTTPS | Fix runner network or proxy settings |
+60
View File
@@ -0,0 +1,60 @@
# Diagnosing a failed run
Open the run in Gitea (Actions) and read the last lines of the failing step.
Every failure from the sync script is written as
```
ERROR: [<category>] <message>
```
The category tells you where to look. A failure outside the script (the job
never starts, or stops before the step) is a **runner** problem.
| Category | Meaning | First thing to check |
| --- | --- | --- |
| `configuration` | A secret or a repository setting is missing or malformed | Secret names and format, push mirror setup |
| `credentials` | A service rejected the token (HTTP 401) | Token expired, revoked or mistyped |
| `permissions` | The token is valid but not allowed (HTTP 403, or no GitHub administration access) | Token scopes and repository access |
| `api` | Another HTTP error, or the service was unreachable | Service status, runner network |
| runner | No `ERROR:` line; the run waits, or the job fails before the script | Runner online, label, tools |
## Messages and fixes
| Message (start) | Category | Cause and fix |
| --- | --- | --- |
| `CREDENTIALS_FOR_GITHUB is missing or empty.` | configuration | Add the secret |
| `CREDENTIALS_FOR_GITHUB must contain valid JSON.` | configuration | The value must be JSON such as `{"GITHUB_PAT": "..."}` |
| `CREDENTIALS_FOR_GITHUB must be a JSON object.` | configuration | Use an object, not a list or string |
| `CREDENTIALS_FOR_GITHUB must contain a non-empty GITHUB_PAT.` | configuration | Add the `GITHUB_PAT` key with a token |
| `TOKEN_FOR_GITEA is missing or empty.` | configuration | Add the secret |
| `TOKEN_FOR_GITEA must contain a non-empty GITEA_TOKEN.` | configuration | The JSON form needs a `GITEA_TOKEN` key |
| `Could not determine the Gitea source repository.` | configuration | The run has no repository name; report as a defect |
| `Expected exactly one GitHub push mirror ... found 0` | configuration | Add a GitHub push mirror in the repository settings |
| `Expected exactly one GitHub push mirror ... found N` | configuration | Keep one GitHub push mirror |
| `Could not determine the GitHub owner and repository ...` | configuration | The mirror address must look like `https://github.com/<owner>/<repo>.git` |
| `Gitea API request failed with HTTP 401` | credentials | Create a new Gitea token and update `TOKEN_FOR_GITEA` |
| `GitHub API request failed with HTTP 401` | credentials | Create a new GitHub token and update `CREDENTIALS_FOR_GITHUB` |
| `... HTTP 403 ...` (Gitea) | permissions | Token needs `read:repository`, and its user needs administrator access to the repository |
| `... HTTP 403 ...` (GitHub) | permissions | Token needs Administration read and write on the mirror repository |
| `The GitHub token cannot administer <repo>` | permissions | Same as above; the token can read the repository but not edit it |
| `... HTTP 404 ...` | configuration | Repository not found, or the token cannot see it; check the mirror address and token repository access |
| `... HTTP 5xx ...` | api | Service problem; re-run later |
| `... API is unreachable ...` | api | Network, DNS or proxy problem on the runner |
## Not an error
`WARNING: Gitea returned an invalid push mirror list; GitHub metadata was not
synced.` The run succeeds but nothing was synced. Check that the Gitea
version supports the push mirror API and re-run.
## Runner problems
See the [Runners](onboarding.md#runners) section. In short: if the run stays
`waiting`, no online runner has the label; if the job fails before the
script, the runner image is missing a tool.
## Re-running
Use **Run workflow** (`workflow_dispatch`) after fixing the cause. The sync is
safe to repeat: it overwrites the GitHub description and topics with the
Gitea values.
+81 -8
View File
@@ -119,23 +119,68 @@ class TestFindGithubTargets:
class TestRequestJson:
def test_maps_http_error_without_leaking_the_response_body(self, sync, monkeypatch):
@staticmethod
def raise_http_error(sync, monkeypatch, code, reason):
def fake_urlopen(request, timeout):
raise urllib.error.HTTPError(
request.full_url, 403, "Forbidden", {}, io.BytesIO(b"secret detail")
request.full_url, code, reason, {}, io.BytesIO(b"secret detail")
)
monkeypatch.setattr(sync["urllib"].request, "urlopen", fake_urlopen)
@pytest.mark.parametrize(
("code", "reason", "category"),
[
(401, "Unauthorized", "credentials"),
(403, "Forbidden", "permissions"),
(404, "Not Found", "configuration"),
(500, "Server Error", "api"),
],
)
def test_categorizes_http_errors(self, sync, monkeypatch, code, reason, category):
self.raise_http_error(sync, monkeypatch, code, reason)
with pytest.raises(sync["WorkflowError"]) as caught:
sync["request_json"]("https://example.test/x")
assert caught.value.category == category
assert str(caught.value).startswith(f"[{category}] Gitea API request failed")
assert f"HTTP {code} ({reason})" in str(caught.value)
def test_does_not_leak_the_response_body(self, sync, monkeypatch):
self.raise_http_error(sync, monkeypatch, 403, "Forbidden")
with pytest.raises(RuntimeError) as caught:
sync["request_json"]("https://example.test/x")
assert str(caught.value) == "API request failed with HTTP 403 (Forbidden)"
assert "secret detail" not in str(caught.value)
def test_names_github_for_github_urls(self, sync, monkeypatch):
self.raise_http_error(sync, monkeypatch, 401, "Unauthorized")
with pytest.raises(RuntimeError, match="GitHub API request failed"):
sync["request_json"]("https://api.github.com/repos/Org/repo")
def test_reports_unreachable_service_as_api_failure(self, sync, monkeypatch):
def fake_urlopen(request, timeout):
raise urllib.error.URLError("name resolution failed")
monkeypatch.setattr(sync["urllib"].request, "urlopen", fake_urlopen)
with pytest.raises(sync["WorkflowError"]) as caught:
sync["request_json"]("https://example.test/x")
assert caught.value.category == "api"
assert "unreachable" in str(caught.value)
class TestWorkflowError:
def test_configuration_errors_carry_their_category(self, sync):
with pytest.raises(sync["WorkflowError"]) as caught:
sync["parse_github_token"]("")
assert caught.value.category == "configuration"
assert str(caught.value).startswith("[configuration] ")
class FakeApi:
"""Records calls and answers like the Gitea and GitHub REST APIs."""
def __init__(self, mirrors, source=None):
def __init__(self, mirrors, source=None, github=None):
self.mirrors = mirrors
self.github = github if github is not None else {"permissions": {"admin": True}}
self.source = source or {"description": "Desc", "topics": ["a", "b"]}
self.calls = []
@@ -145,6 +190,8 @@ class FakeApi:
return self.mirrors
if url.startswith("https://git.example.test"):
return self.source
if method == "GET" and url.startswith("https://api.github.com"):
return self.github
return None
@@ -169,13 +216,14 @@ class TestMain:
assert requests == [
("GET", "https://git.example.test/api/v1/repos/TirSystem/repo"),
("GET", "https://git.example.test/api/v1/repos/TirSystem/repo/push_mirrors"),
("GET", "https://api.github.com/repos/Org/repo"),
("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 api.calls[3][3] == {"description": "Desc"}
assert api.calls[4][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):
@@ -187,8 +235,8 @@ class TestMain:
sync["main"]()
assert api.calls[2][3] == {"description": ""}
assert api.calls[3][3] == {"names": []}
assert api.calls[3][3] == {"description": ""}
assert api.calls[4][3] == {"names": []}
def test_skips_with_a_warning_when_mirror_list_is_invalid(
self, sync, environment, capsys
@@ -211,3 +259,28 @@ class TestMain:
with pytest.raises(RuntimeError, match="CREDENTIALS_FOR_GITHUB"):
sync["main"]()
assert api.calls == []
def test_stops_before_any_change_when_github_token_cannot_administer(
self, sync, environment
):
api = FakeApi(
[{"remote_address": "git@github.com:Org/repo.git"}],
github={"permissions": {"admin": False, "push": True}},
)
sync["request_json"] = api
with pytest.raises(sync["WorkflowError"]) as caught:
sync["main"]()
assert caught.value.category == "permissions"
assert all(call[0] == "GET" for call in api.calls)
def test_does_not_print_any_secret(self, sync, environment, capsys):
api = FakeApi([{"remote_address": "git@github.com:Org/repo.git"}])
sync["request_json"] = api
sync["main"]()
output = capsys.readouterr()
assert "gh-token" not in output.out + output.err
assert "gitea-token" not in output.out + output.err