diff --git a/.lokf/knowledge/glossary/repository-ruleset.md b/.lokf/knowledge/glossary/repository-ruleset.md index 65ef747..f2580e8 100644 --- a/.lokf/knowledge/glossary/repository-ruleset.md +++ b/.lokf/knowledge/glossary/repository-ruleset.md @@ -19,11 +19,10 @@ sources: title: the rules with no check - id: security.md resource: SECURITY.md - title: the settings not yet in force + title: the ruleset on main generated: by: process:ktl-librarian - at: "2026-10-28T19:53:06Z" - revision: 4e47f1efc96b84011e7016edd847a71bfc692a33 + at: "2026-10-30T15:31:58Z" status: draft verified: - by: process:ktl-librarian @@ -50,7 +49,7 @@ A comment says that SLSA's Source track calls such rules technical controls on p ## In this repository -SECURITY.md says that a rule blocking deletion and force-pushes on `main` is a repository setting, not a file, and that it is not yet in force.[^security.md] +SECURITY.md says that a ruleset on `main`, a repository setting rather than a file, blocks deletion and force-pushes and keeps the history linear.[^security.md] [^github_actions_rules.yaml]: src/github_actions/schema/github_actions_rules.yaml, GhaRepositoryRuleset, GhaBypassActor and the enums [^docs-index.md]: docs/index.md, the Validating data section diff --git a/.lokf/knowledge/index.md b/.lokf/knowledge/index.md index 2a4b46d..b658881 100644 --- a/.lokf/knowledge/index.md +++ b/.lokf/knowledge/index.md @@ -58,7 +58,7 @@ A [LOKF](https://lokf.nolan-nichols.com) knowledge base for github-actions. Ever # Policies * [License and attribution](policies/license-and-attribution.md) - Which license covers what in the github-actions repository: Apache-2.0 for the schema, the generated models and the code; SchemaStore's two schemas under Apache-2.0 with JSON Schema Store's notice; GitHub's documentation, under CC BY 4.0, cited and never copied; the linkml-project-copier scaffolding under MIT; the KTL sidecar and site templates under Apache-2.0; and the bundle under CC BY 4.0. -* [Security policy](policies/security-policy.md) - How to report a vulnerability in github-actions (GitHub's private vulnerability reporting, not a public issue), which release gets fixes, what the repository releases and runs, what holds each workflow, and the three repository settings not yet in force: required reviewers on the release Environment, a deletion and force-push rule on main, and secret scanning with push protection. +* [Security policy](policies/security-policy.md) - How to report a vulnerability in github-actions (GitHub's private vulnerability reporting, not a public issue), which release gets fixes, what the repository releases and runs, what holds each workflow, the ruleset that holds main, and the two repository settings not yet in force: required reviewers on the release Environment, and secret scanning with push protection. * [AI covenant](policies/ai-covenant.md) - The rules for AI-assisted work in github-actions: a contributor owns what they submit; a schema description says what its GitHub, SchemaStore or build-type source says and cites the page; GitHub's documentation is cited, never copied; the SchemaStore copy is never edited; and the scheduled librarian works through pull requests a person approves, with least privilege. # Workflows diff --git a/.lokf/knowledge/log.md b/.lokf/knowledge/log.md index 3682c3e..96099ca 100644 --- a/.lokf/knowledge/log.md +++ b/.lokf/knowledge/log.md @@ -1,5 +1,10 @@ # Change Log +## 2026-10-30 + +* **Changed**: the export script prints an organization's rulesets with `rulesets --organization`. +* **Changed**: SECURITY.md names the ruleset that holds `main`. + ## 2026-10-29 * **Prose**: reworded 2 concept bodies in plain English. No fact, link, number or frontmatter changed. diff --git a/.lokf/knowledge/playbooks/derive-and-check.md b/.lokf/knowledge/playbooks/derive-and-check.md index 05109fe..b04e146 100644 --- a/.lokf/knowledge/playbooks/derive-and-check.md +++ b/.lokf/knowledge/playbooks/derive-and-check.md @@ -16,7 +16,7 @@ sources: title: the PEP 723 header, the docstring, the options and the comparisons - id: github_export.py resource: scripts/github_export.py - title: the docstring and the two commands + title: the docstring, the two commands and the --organization option - id: readme.md resource: README.md title: the Deriving the facts from a repository section @@ -36,8 +36,7 @@ sources: title: the fixture repository and the two export files generated: by: process:ktl-librarian - at: "2026-10-29T16:44:32Z" - revision: cc14f8e2bd682e256323ad6fcc7cd655e28798b9 + at: "2026-10-30T15:31:58Z" status: draft verified: - by: process:ktl-librarian @@ -58,9 +57,10 @@ derive.py's only dependency is PyYAML, declared in its PEP 723 header, so `uv ru uv run scripts/derive.py --root ../host-repository uv run scripts/derive.py --root ../host-repository --check ../host-repository/.lokf/knowledge python3 scripts/github_export.py rulesets --repository OWNER/REPO > rulesets.md +python3 scripts/github_export.py rulesets --organization OWNER > organization-rulesets.md python3 scripts/github_export.py check-runs SHA --repository OWNER/REPO > check-runs.md uv run scripts/derive.py --root ../host-repository --check ../host-repository/.lokf/knowledge \ - --rulesets rulesets.md --check-runs check-runs.md + --rulesets rulesets.md --rulesets organization-rulesets.md --check-runs check-runs.md ``` derive.py takes these options: @@ -72,7 +72,7 @@ derive.py takes these options: - `--check-runs FILE`, with `--check`, reads what `github_export.py check-runs` printed for one commit, such as the head of a recent pull request. - `--strict` makes the check fail on findings as well as on drift. -On a private repository, `GH_TOKEN` holds a token with Metadata read, and Checks and Commit statuses read for `check-runs`. The README says that a host copies both scripts from a pinned release and records the version beside them. Its ingest can run `github_export.py rulesets` as an entry of `kind: command`, and the mirror file becomes the rulesets' source. +On a private repository, `GH_TOKEN` holds a token with Metadata read, and Checks and Commit statuses read for `check-runs`. `rulesets --organization OWNER` prints an organization's rulesets, which a repository's own list holds only when their conditions select it. The README says that GitHub answers it only to a token with the organization's Administration permission, which can also change those rulesets, so a person runs it and commits what it prints rather than giving the ingest that token. The README says that a host copies both scripts from a pinned release and records the version beside them. Its ingest can run `github_export.py rulesets` as an entry of `kind: command`, and the mirror file becomes the rulesets' source. In this repository, pass `--exclude tests/`. The script looks for action files in the whole tree, and would otherwise also read the two fixture actions under `tests/derive/repo/`, which no concept describes. So the check of this bundle, run from the repository root, is: diff --git a/.lokf/knowledge/policies/index.md b/.lokf/knowledge/policies/index.md index 6ea5acb..e312514 100644 --- a/.lokf/knowledge/policies/index.md +++ b/.lokf/knowledge/policies/index.md @@ -1,5 +1,5 @@ # Policies * [License and attribution](license-and-attribution.md) - Which license covers what in the github-actions repository: Apache-2.0 for the schema, the generated models and the code; SchemaStore's two schemas under Apache-2.0 with JSON Schema Store's notice; GitHub's documentation, under CC BY 4.0, cited and never copied; the linkml-project-copier scaffolding under MIT; the KTL sidecar and site templates under Apache-2.0; and the bundle under CC BY 4.0. -* [Security policy](security-policy.md) - How to report a vulnerability in github-actions (GitHub's private vulnerability reporting, not a public issue), which release gets fixes, what the repository releases and runs, what holds each workflow, and the three repository settings not yet in force: required reviewers on the release Environment, a deletion and force-push rule on main, and secret scanning with push protection. +* [Security policy](security-policy.md) - How to report a vulnerability in github-actions (GitHub's private vulnerability reporting, not a public issue), which release gets fixes, what the repository releases and runs, what holds each workflow, the ruleset that holds main, and the two repository settings not yet in force: required reviewers on the release Environment, and secret scanning with push protection. * [AI covenant](ai-covenant.md) - The rules for AI-assisted work in github-actions: a contributor owns what they submit; a schema description says what its GitHub, SchemaStore or build-type source says and cites the page; GitHub's documentation is cited, never copied; the SchemaStore copy is never edited; and the scheduled librarian works through pull requests a person approves, with least privilege. diff --git a/.lokf/knowledge/policies/security-policy.md b/.lokf/knowledge/policies/security-policy.md index 436c31e..9a5b042 100644 --- a/.lokf/knowledge/policies/security-policy.md +++ b/.lokf/knowledge/policies/security-policy.md @@ -3,7 +3,7 @@ type: Policy id: https://w3id.org/lmodel/github-actions/knowledge/policies/security-policy genre: reference title: Security policy -description: "How to report a vulnerability in github-actions (GitHub's private vulnerability reporting, not a public issue), which release gets fixes, what the repository releases and runs, what holds each workflow, and the three repository settings not yet in force: required reviewers on the release Environment, a deletion and force-push rule on main, and secret scanning with push protection." +description: "How to report a vulnerability in github-actions (GitHub's private vulnerability reporting, not a public issue), which release gets fixes, what the repository releases and runs, what holds each workflow, the ruleset that holds main, and the two repository settings not yet in force: required reviewers on the release Environment, and secret scanning with push protection." resource: SECURITY.md references: - https://w3id.org/lmodel/github-actions/knowledge/workflows/main @@ -24,7 +24,7 @@ sources: title: the three update entries generated: by: process:ktl-librarian - at: "2026-10-28T21:21:37Z" + at: "2026-10-30T15:31:58Z" status: draft --- @@ -56,9 +56,9 @@ The repository releases a LinkML schema, the Python datamodel generated from it, SECURITY.md says that every workflow declares `permissions: {}` at the top, pins its actions to commit SHAs that Dependabot keeps current, and runs harden-runner in audit mode. `.github/dependabot.yml` asks Dependabot for monthly updates of the workflows' actions, of the package's `uv` dependencies and of the sidecar's `uv` dependencies in `.lokf/`.[^dependabot.yml] -## Settings not yet in force +## Settings -Three protections are repository settings, not files, and SECURITY.md says that they are not yet in force: required reviewers on the `release` Environment, a rule blocking deletion and force-pushes on `main`, and secret scanning with push protection. Until they are, a qualifying merge releases with no human step. Nothing in CI can assert them, so keeping them on is the maintainer's job. +Three protections are repository settings, not files. SECURITY.md says that a ruleset on `main` blocks deletion and force-pushes and keeps the history linear, and that two are not yet in force: required reviewers on the `release` Environment, and secret scanning with push protection. Until they are, a qualifying merge releases with no human step. Nothing in CI can assert them, so keeping them on is the maintainer's job. ## Not covered diff --git a/CHANGELOG.md b/CHANGELOG.md index c4d603f..c6997b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The `v0.0.0` tag is a baseline, not a release: it marks where versioning starts ## [Unreleased] +### Added + +- **`github_export.py rulesets --organization OWNER` prints an organization's rulesets, which `derive.py --rulesets` reads beside a repository's.** GitHub answers it only to a token with the organization's Administration permission. + +### Fixed + +- **SECURITY.md says that a ruleset on `main` blocks deletion and force-pushes and keeps the history linear.** It said that no such rule was in force. + ## [0.2.1] - 2026-10-29 ### Fixed diff --git a/README.md b/README.md index 60437b5..ae5991d 100644 --- a/README.md +++ b/README.md @@ -52,15 +52,16 @@ Runs and events, such as CDEvents and GitHub's run logs, are records, not facts With `--check` and a bundle, it exits 1 on drift between the files and the concepts, on a required check that no job of its `emitted_by` workflow reports, or on a skip condition that a check's body does not name. Each skip condition is a finding, which fails the check under `--strict`. A job skipped by its own `if`, or by a failed or skipped job it `needs`, lets a required check pass without running. A path filter, or a calling job's `if` or `needs`, leaves the check pending, which blocks the merge. -`scripts/github_export.py` asks GitHub's REST API what the files cannot say. `rulesets` prints the repository's rulesets and the rules in effect on its default branch, which `derive.py --rulesets` compares with the ruleset and check concepts. `check-runs` prints what GitHub recorded on one commit, such as the head of a recent pull request, and `derive.py --check-runs` reports each required check that no run on it matched. On a private repository, `GH_TOKEN` holds a token with Metadata read, and Checks and Commit statuses read for `check-runs`. +`scripts/github_export.py` asks GitHub's REST API what the files cannot say. `rulesets` prints the repository's rulesets and the rules in effect on its default branch, which `derive.py --rulesets` compares with the ruleset and check concepts. `check-runs` prints what GitHub recorded on one commit, such as the head of a recent pull request, and `derive.py --check-runs` reports each required check that no run on it matched. On a private repository, `GH_TOKEN` holds a token with Metadata read, and Checks and Commit statuses read for `check-runs`. `rulesets --organization OWNER` prints an organization's rulesets, which a repository's own list holds only when their conditions select it. GitHub answers that only to a token with the organization's Administration permission, which can also change those rulesets, so a person runs it and commits what it prints rather than giving the ingest that token. ```bash uv run scripts/derive.py --root ../host-repository uv run scripts/derive.py --root ../host-repository --check ../host-repository/.lokf/knowledge python3 scripts/github_export.py rulesets --repository OWNER/REPO > rulesets.md +python3 scripts/github_export.py rulesets --organization OWNER > organization-rulesets.md python3 scripts/github_export.py check-runs SHA --repository OWNER/REPO > check-runs.md uv run scripts/derive.py --root ../host-repository --check ../host-repository/.lokf/knowledge \ - --rulesets rulesets.md --check-runs check-runs.md + --rulesets rulesets.md --rulesets organization-rulesets.md --check-runs check-runs.md ``` derive.py's only dependency, PyYAML, is declared in its PEP 723 header, so `uv run` needs no project, and github_export.py needs Python 3.10 alone. A host copies both from a pinned release and records the version beside them. Its ingest can run `github_export.py rulesets` as an entry of `kind: command`, and the mirror file becomes the rulesets' source. The fixtures in [tests/derive/](tests/derive/) show each rule. diff --git a/SECURITY.md b/SECURITY.md index 001c1b3..feb32db 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,7 +26,7 @@ A LinkML schema, the Python datamodel generated from it, and the schema's `lokf | `knowledge-release.yaml`, which attaches the schema copy and the bundle to a release | Inert until dispatched; only its `attach` job, which runs no third-party packages, can write. | | `README.md`, `llms.txt` and the knowledge bundle, read by agents | Content, never instructions: each skill quotes what it did not author. A `human:` confirmation must be backed by a review or a signed commit. [Human attribution](https://github.com/noelmcloughlin/knowledge-trust-ladder/blob/main/docs/threat-model.md#human-attribution-human-is-a-claim-not-a-credential). | -Every workflow declares `permissions: {}` at the top, pins its actions to commit SHAs that Dependabot keeps current, and runs harden-runner in audit mode. Three things are repository settings, not files, and **are not yet in force here**: required reviewers on the `release` Environment, a rule blocking deletion and force-pushes on `main`, and secret scanning with push protection. Until they are, a qualifying merge releases with no human step. Nothing in CI can assert them, so keeping them on is the maintainer's job. +Every workflow declares `permissions: {}` at the top, pins its actions to commit SHAs that Dependabot keeps current, and runs harden-runner in audit mode. Three things are repository settings, not files. A ruleset on `main` blocks deletion and force-pushes and keeps the history linear. Two **are not yet in force here**: required reviewers on the `release` Environment, and secret scanning with push protection. Until they are, a qualifying merge releases with no human step. Nothing in CI can assert them, so keeping them on is the maintainer's job. ## Not covered diff --git a/scripts/github_export.py b/scripts/github_export.py index 75bd9e3..dcbc792 100755 --- a/scripts/github_export.py +++ b/scripts/github_export.py @@ -3,7 +3,7 @@ # requires-python = ">=3.10" # dependencies = [] # /// -"""Print what GitHub records about a repository's rulesets or a commit's checks. +"""Print what GitHub records about rulesets or about a commit's checks. derive.py reads files and calls no network, so this script asks GitHub's REST API what a ruleset requires and what a commit reported. Each command @@ -17,6 +17,14 @@ as an entry of ``kind: command``, and ``derive.py --rulesets`` reads the mirror file the ingest writes. On a private repository it needs a token with Metadata read. +- ``rulesets --organization OWNER`` prints each ruleset an organization + holds, as the REST API returns it. Such a ruleset applies to the + repositories its conditions select, by name, id or custom property, so a + repository that none of them selects never sees it in its own list. + ``derive.py --rulesets`` reads this beside a repository's export. GitHub + answers only a token with the organization's Administration permission, + which its permissions table lists as write, or a classic token with the + ``admin:org`` scope, and it answers 404 to any other token. - ``check-runs REF`` prints the name, app and conclusion of each check run on a commit, and the context and state of each commit status, for ``derive.py --check-runs``. On a private repository it needs Checks read and @@ -33,11 +41,13 @@ The ingest passes a command only the variables its ``auth`` block names, so an entry for GitHub Enterprise Server puts ``--api-url`` in its args. The repository is ``--repository``, or the one the origin remote names. +``--organization`` takes its place for an organization's rulesets. It needs Python 3.10 and nothing else, so the ingest can run it as a tracked command with whatever ``python3`` the job has. Usage:: python3 scripts/github_export.py rulesets [--repository OWNER/REPO] + python3 scripts/github_export.py rulesets --organization OWNER python3 scripts/github_export.py check-runs REF [--repository OWNER/REPO] """ @@ -58,6 +68,8 @@ API_VERSION = "2026-03-10" # OWNER/REPO, neither of them `.` or `..`, which would leave the path. REPOSITORY = re.compile(r"^(?!\.\.?/)[A-Za-z0-9_.-]+/(?!\.\.?$)[A-Za-z0-9_.-]+$") +# An organization's login, which is neither `.` nor `..` either. +OWNER = re.compile(r"^(?!\.\.?$)[A-Za-z0-9_.-]+$") # The origin remote's owner and name, over https or ssh, with or without .git. REMOTE = re.compile(r"[:/]([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?/?$") TOKENS = ("KNOWLEDGE_INGEST_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN") @@ -90,7 +102,9 @@ def _get(api: str, path: str, **params): return json.load(resp) except urllib.error.HTTPError as err: hint = { - 404: ": the repository, ref or ruleset is not there, or the token cannot read it", + 401: ": GitHub answers this only to a token, and none is set or it has expired", + 404: ": the repository, organization, ref or ruleset is not there, or the token" + " cannot read it; an organization's rulesets need its Administration permission", 403: ": the token lacks a permission, or the rate limit is spent", 429: ": the rate limit is spent", }.get(err.code, "") @@ -130,16 +144,22 @@ def origin_repository() -> str | None: return match.group(1) if match else None -def rulesets(api: str, repository: str) -> dict: - """The repository's rulesets in full, and the rules in effect on its default branch.""" - branch = _get(api, f"/repos/{repository}").get("default_branch") - listed, _ = _pages(api, f"/repos/{repository}/rulesets") +def _full_rulesets(api: str, at: str) -> list[dict]: + """Each ruleset listed under a repository's or an organization's path, in full, by id.""" + listed, _ = _pages(api, f"{at}/rulesets") full = [] for item in sorted(listed, key=lambda r: r.get("id") or 0): - ruleset = _get(api, f"/repos/{repository}/rulesets/{int(item['id'])}") + ruleset = _get(api, f"{at}/rulesets/{int(item['id'])}") for field in ASKER_FIELDS: ruleset.pop(field, None) full.append(ruleset) + return full + + +def rulesets(api: str, repository: str) -> dict: + """The repository's rulesets in full, and the rules in effect on its default branch.""" + branch = _get(api, f"/repos/{repository}").get("default_branch") + full = _full_rulesets(api, f"/repos/{repository}") rules = [] if branch: rules, _ = _pages( @@ -157,6 +177,15 @@ def rulesets(api: str, repository: str) -> dict: } +def organization_rulesets(api: str, organization: str) -> dict: + """The organization's rulesets in full, each with the conditions that select its repositories.""" + return { + "api_version": API_VERSION, + "organization": organization, + "rulesets": _full_rulesets(api, f"/orgs/{organization}"), + } + + def check_runs(api: str, repository: str, ref: str) -> dict: """The check runs and commit statuses GitHub recorded on one commit.""" at = f"/repos/{repository}/commits/{urllib.parse.quote(ref, safe='/')}" @@ -207,11 +236,16 @@ def main(argv: list[str] | None = None) -> int: ) parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) sub = parser.add_subparsers(dest="command", required=True) - sub.add_parser( + sets = sub.add_parser( "rulesets", parents=[common], help="The rulesets, and the rules in effect on the default branch", ) + sets.add_argument( + "--organization", + metavar="OWNER", + help="Print the organization's rulesets instead of a repository's", + ) runs = sub.add_parser( "check-runs", parents=[common], @@ -224,6 +258,25 @@ def main(argv: list[str] | None = None) -> int: parser.error( "--api-url is an https address, so the token never travels in clear" ) + organization = getattr(args, "organization", None) + if organization is not None: + if args.repository: + parser.error("give --organization or --repository, not both") + if not OWNER.match(organization): + parser.error("--organization is an organization's login") + data = organization_rulesets(api, organization) + sys.stdout.write( + markdown( + f"Rulesets of the {organization} organization", + f"The rulesets the {organization} organization holds, as GitHub's REST API" + f" (version {API_VERSION}) returns them. Each one applies to the" + " repositories its conditions select. `scripts/github_export.py rulesets" + " --organization` printed this, and `scripts/derive.py --rulesets` reads" + " the JSON below.", + data, + ) + ) + return 0 repository = args.repository or origin_repository() if not repository or not REPOSITORY.match(repository): parser.error("--repository is OWNER/REPO, or the origin remote names one") diff --git a/tests/test_github_export.py b/tests/test_github_export.py index 18d02ac..d4a7490 100644 --- a/tests/test_github_export.py +++ b/tests/test_github_export.py @@ -79,6 +79,66 @@ def test_rulesets(monkeypatch): ] +def test_organization_rulesets(monkeypatch): + """Each of the organization's rulesets in full by id, conditions kept, without what depends on the asker.""" + org = "/orgs/example-org" + selects = { + "ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}, + "repository_property": { + "include": [{"name": "tier", "property_values": ["core"]}], + "exclude": [], + }, + } + calls = _answers( + monkeypatch, + { + f"{org}/rulesets": [{"id": 31}, {"id": 7}], + f"{org}/rulesets/7": {"id": 7, "conditions": selects}, + f"{org}/rulesets/31": {"id": 31, "current_user_can_bypass": "never"}, + }, + ) + data = export.organization_rulesets("https://api.example", "example-org") + assert data == { + "api_version": export.API_VERSION, + "organization": "example-org", + "rulesets": [{"id": 7, "conditions": selects}, {"id": 31}], + } + assert not any(path.startswith("/repos/") for path, _ in calls) + + +def test_derive_reads_an_organization_export_beside_a_repository_one(tmp_path): + """The organization's rulesets join the repository's, once each, and the repository's rules still say what binds.""" + required = { + "type": "required_status_checks", + "parameters": {"required_status_checks": [{"context": "org / scan"}]}, + } + printed = tmp_path / "organization.md" + printed.write_text( + export.markdown( + "Rulesets of the example-org organization", + "A sentence.", + { + "organization": "example-org", + # 202 is in the repository's export too, since it applies there. + "rulesets": [ + {"id": 202, "name": "docs trial"}, + {"id": 7, "name": "core", "rules": [required]}, + ], + }, + ), + encoding="utf-8", + ) + path = Path(__file__).parent / "derive" / "rulesets.md" + evidence = derive.read_rulesets([path, printed]) + assert [r["id"] for r in evidence["rulesets"]] == [101, 202, 7] + assert evidence["default_branch"] == "main" + checks = derive.required_checks(evidence) + # No rule in effect on the repository's default branch requires it, since + # ruleset 7's conditions do not select the repository. + assert checks["org / scan"]["required_by"] == [7] + assert checks["org / scan"]["in_effect"] is False + + def test_check_runs(monkeypatch): """Each check run's name, app and conclusion, and each status, sorted.""" at = "/repos/example-org/repo/commits/feature/x" @@ -209,10 +269,32 @@ def test_a_repository_named_with_a_dot_is_accepted(): ["rulesets", "--repository", "../repo"], ["check-runs", "--repository", "example-org/repo"], ["check-runs", "../../user", "--repository", "example-org/repo"], + ["rulesets", "--organization", ".."], + ["rulesets", "--organization", "example-org/repo"], + [ + "rulesets", + "--organization", + "example-org", + "--repository", + "example-org/repo", + ], + [ + "rulesets", + "--organization", + "example-org", + "--api-url", + "http://api.example", + ], + ["check-runs", "SHA", "--organization", "example-org"], ], ) -def test_main_refuses(argv): - """Plain http, a malformed repository and a missing or malformed ref stop before any call.""" +def test_main_refuses(argv, monkeypatch): + """Plain http, a malformed repository, organization or ref, and both owners at once stop before any call.""" + + def no_call(*args, **kwargs): + raise AssertionError("the API was called") + + monkeypatch.setattr(export, "_get", no_call) with pytest.raises(SystemExit) as stop: export.main(argv) assert stop.value.code == 2 @@ -237,3 +319,23 @@ def test_main_prints_markdown(monkeypatch, capsys): json.loads(out[out.index("```json\n") + 8 : out.rindex("\n```")])["rulesets"] == [] ) + + +def test_main_prints_an_organization_export(monkeypatch, capsys): + """--organization asks for the organization's rulesets and never reads the origin remote.""" + monkeypatch.setattr( + export, + "organization_rulesets", + lambda api, org: {"organization": org, "rulesets": [{"id": 7}]}, + ) + monkeypatch.setattr( + export, + "origin_repository", + lambda: (_ for _ in ()).throw(AssertionError("origin read")), + ) + assert export.main(["rulesets", "--organization", "example-org"]) == 0 + out = capsys.readouterr().out + assert out.startswith("# Rulesets of the example-org organization\n") + assert "`scripts/github_export.py rulesets --organization`" in out + data = json.loads(out[out.index("```json\n") + 8 : out.rindex("\n```")]) + assert data == {"organization": "example-org", "rulesets": [{"id": 7}]}