From c26b2da315e1257b5cadf40af095a2a0c31cadaa Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Thu, 22 Oct 2026 19:27:38 -0400 Subject: [PATCH 1/6] feat(maintenance): add plugin unlisting workflow Add a maintainer-authorized unlisting issue form and workflow that opens a tracked metadata PR. Require repository-only PRs to pass [repo] title validation before the protected gate succeeds. --- .../plugin-unlisting-request.yml | 40 ++++++ .github/pluginctl/src/pluginctl/cli.py | 8 ++ .../pluginctl/src/pluginctl/label_catalog.py | 1 + .../pluginctl/src/pluginctl/publish/unlist.py | 121 ++++++++++++++++++ .../pluginctl/src/pluginctl/validate/gate.py | 4 +- .github/pluginctl/tests/test_labels_gate.py | 9 +- .github/pluginctl/tests/test_title.py | 4 + .github/pluginctl/tests/test_unlist.py | 72 +++++++++++ .github/workflows/unlist-plugin.yml | 71 ++++++++++ .github/workflows/validate-plugin.yml | 2 +- CONTRIBUTING.md | 6 + docs/maintainer-operations.md | 1 + 12 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/plugin-unlisting-request.yml create mode 100644 .github/pluginctl/src/pluginctl/publish/unlist.py create mode 100644 .github/pluginctl/tests/test_unlist.py create mode 100644 .github/workflows/unlist-plugin.yml diff --git a/.github/ISSUE_TEMPLATE/plugin-unlisting-request.yml b/.github/ISSUE_TEMPLATE/plugin-unlisting-request.yml new file mode 100644 index 00000000..94535ba5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/plugin-unlisting-request.yml @@ -0,0 +1,40 @@ +name: Plugin Unlisting Request +description: Request that a plugin be removed from registry discovery without removing its releases. +labels: ["Plugin Unlisting"] +body: + - type: markdown + attributes: + value: | + Use this form to request that a plugin be unlisted from the registry. Unlisting removes it from registry discovery and the releases README after a maintainer merges the resulting pull request. Existing releases remain available. + + This request does not authorize an action by itself. Only a repository maintainer can dispatch the unlisting workflow, including for plugins they do not own or maintain. + + - type: input + id: plugin + attributes: + label: Plugin slug + description: The plugin folder name (for example, `dispatcharr-exporter`). + placeholder: my-plugin + validations: + required: true + + - type: dropdown + id: reason + attributes: + label: Reason for unlisting + options: + - Unsupported or abandoned + - Security or trust concern + - Replaced by another plugin + - Author request + - Other + validations: + required: true + + - type: textarea + id: details + attributes: + label: Details + description: Explain why the plugin should no longer be listed and include relevant links or context. + validations: + required: true diff --git a/.github/pluginctl/src/pluginctl/cli.py b/.github/pluginctl/src/pluginctl/cli.py index 6db37ef9..8c55798e 100644 --- a/.github/pluginctl/src/pluginctl/cli.py +++ b/.github/pluginctl/src/pluginctl/cli.py @@ -347,6 +347,11 @@ def cmd_yank(args) -> int: return yank.run() +def cmd_unlist(args) -> int: + from .publish import unlist + return unlist.run() + + def cmd_automerge(args) -> int: from .integrations import automerge return automerge.run(head_sha=args.head_sha or _env("HEAD_SHA"), disabled_reason=args.disabled_reason) @@ -454,6 +459,9 @@ def build_parser() -> argparse.ArgumentParser: p = sub.add_parser("yank", help="Yank a plugin version (env-driven)") p.set_defaults(func=cmd_yank) + p = sub.add_parser("unlist", help="Open a PR to unlist a plugin (env-driven)") + p.set_defaults(func=cmd_unlist) + p = sub.add_parser("automerge", help="Auto-merge a pure plugin-update PR") p.add_argument("--head-sha", default="") p.add_argument("--disabled-reason", default="") diff --git a/.github/pluginctl/src/pluginctl/label_catalog.py b/.github/pluginctl/src/pluginctl/label_catalog.py index 2a05951b..12ff61f5 100644 --- a/.github/pluginctl/src/pluginctl/label_catalog.py +++ b/.github/pluginctl/src/pluginctl/label_catalog.py @@ -27,6 +27,7 @@ class Label: Label("Manual Review Required", "CFD3D7", "Security findings, suppressions, or new plugins require maintainer review"), Label("QUARANTINE", "FFFF00", "ClamAV detected a potential threat in this PR"), Label("Rollback", "E11D48", "Version rollback opened by the yank workflow"), + Label("Plugin Unlisting", "B60205", "Maintainer request to remove a plugin from registry discovery"), Label("Auto Merged", "333333", "PR was merged automatically after validation"), ) diff --git a/.github/pluginctl/src/pluginctl/publish/unlist.py b/.github/pluginctl/src/pluginctl/publish/unlist.py new file mode 100644 index 00000000..05476a06 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/publish/unlist.py @@ -0,0 +1,121 @@ +"""Open a maintainer-authorized PR that removes a plugin from registry discovery.""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import tempfile + +from .. import label_catalog +from ..core import actions, gh +from ..core.git import configure_identity, run as _git + +_SAFE_PLUGIN_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +def set_unlisted(plugin_json: str) -> bool: + """Set ``unlisted`` to true, returning false when it is already true.""" + with open(plugin_json, encoding="utf-8") as fh: + data = json.load(fh) + if not isinstance(data, dict): + raise ValueError("plugin.json must contain a JSON object") + if data.get("unlisted") is True: + return False + data["unlisted"] = True + with open(plugin_json, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2, ensure_ascii=False) + fh.write("\n") + return True + + +def _open_pr_for_branch(repository: str, branch: str) -> str: + owner, _, _ = repository.partition("/") + if not owner: + return "" + pulls = gh.loads(gh.api( + f"repos/{repository}/pulls?state=open&head={owner}:{branch}", + )) + if not isinstance(pulls, list) or not pulls: + return "" + first = pulls[0] if isinstance(pulls[0], dict) else {} + return str(first.get("html_url") or "") + + +def run() -> int: + repository = os.environ["GITHUB_REPOSITORY"] + token = os.environ["GITHUB_TOKEN"] + plugin = os.environ["UNLIST_PLUGIN"] + issue = os.environ["UNLIST_ISSUE"] + source_branch = os.environ["SOURCE_BRANCH"] + app_slug = os.environ.get("APP_SLUG", "") + branch = f"unlist/{plugin}" + remote = f"https://x-access-token:{token}@github.com/{repository}.git" + + if not _SAFE_PLUGIN_RE.fullmatch(plugin): + actions.error(f"Invalid plugin slug: {plugin!r}") + return 1 + if not issue.isdigit(): + actions.error(f"Invalid issue number: {issue!r}") + return 1 + if not label_catalog.ensure(repository): + return 1 + + actions.log(f"Unlisting {plugin} (issue #{issue})") + issue_state = gh.api(f"repos/{repository}/issues/{issue}", jq=".state") + if not issue_state: + actions.error(f"Issue #{issue} not found in {repository}.") + return 1 + if issue_state != "open": + actions.error(f"Issue #{issue} is already {issue_state}. Only open issues can authorize unlisting.") + return 1 + + existing_pr = _open_pr_for_branch(repository, branch) + if existing_pr: + actions.error(f"An unlisting PR for {plugin} is already open: {existing_pr}") + return 1 + + workdir = tempfile.mkdtemp() + start_cwd = os.getcwd() + try: + repo_dir = f"{workdir}/repo" + _git("clone", "--no-checkout", remote, repo_dir) + os.chdir(repo_dir) + configure_identity(app_slug) + _git("fetch", "origin", source_branch) + _git("checkout", "-b", branch, f"origin/{source_branch}") + + plugin_json = f"plugins/{plugin}/plugin.json" + if not os.path.isfile(plugin_json): + actions.error(f"Plugin '{plugin}' does not exist on {source_branch}.") + return 1 + try: + changed = set_unlisted(plugin_json) + except (OSError, ValueError, json.JSONDecodeError) as exc: + actions.error(f"Could not update {plugin_json}: {exc}") + return 1 + if not changed: + actions.error(f"Plugin '{plugin}' is already unlisted.") + return 1 + + _git("add", plugin_json) + _git("commit", "-m", f"Unlist {plugin}\n\nRefs #{issue}") + _git("push", remote, branch) + + title = f"[{plugin}]: Unlist plugin" + body = (f"## Plugin Unlisting\n\n" + f"Sets `unlisted` to `true` for `{plugin}`, removing it from registry discovery " + f"and the releases README after this PR is merged and publishing completes. " + f"Existing releases remain available.\n\n" + f"**Authorized by:** #{issue}\n\nCloses #{issue}") + pr_url = gh.pr_create(repository, source_branch, branch, title, body, + labels=["Plugin Unlisting"]) + if not pr_url: + actions.error("Unlisting branch was pushed, but the pull request could not be created.") + return 1 + actions.log(f"Unlisting PR opened: {pr_url}") + return 0 + finally: + os.chdir(start_cwd) + shutil.rmtree(workdir, ignore_errors=True) diff --git a/.github/pluginctl/src/pluginctl/validate/gate.py b/.github/pluginctl/src/pluginctl/validate/gate.py index d2f71ec5..caa0997d 100644 --- a/.github/pluginctl/src/pluginctl/validate/gate.py +++ b/.github/pluginctl/src/pluginctl/validate/gate.py @@ -29,10 +29,10 @@ def evaluate(detect_result: str, close_pr: str, skip_validation: str, # early-pass so a pure repo update cannot merge with red tests. if test_result in ("failure", "cancelled"): return GateResult(False, "Tooling test suite failed. See the Test Suite job for details.") - if skip_validation == "true": - return GateResult(True, "No plugin changes detected and author has write access - passing.") if title_result == "failure": return GateResult(False, "PR title does not match the required format. Rename the PR and re-run.") + if skip_validation == "true": + return GateResult(True, "No plugin changes detected and author has write access - passing.") if outside_violation == "true": return GateResult(False, "PR contains unauthorized changes outside the plugins/ directory.") if close_pr == "true": diff --git a/.github/pluginctl/tests/test_labels_gate.py b/.github/pluginctl/tests/test_labels_gate.py index 96801737..00872eed 100644 --- a/.github/pluginctl/tests/test_labels_gate.py +++ b/.github/pluginctl/tests/test_labels_gate.py @@ -54,10 +54,15 @@ def test_gate_detect_failure(): def test_gate_skip_validation_passes(): - r = gate.evaluate("success", "false", "true", "false", "", "", "", "", "", "", "") + r = gate.evaluate("success", "false", "true", "false", "success", "", "", "", "", "", "") assert r.ok +def test_gate_skip_validation_fails_for_invalid_title(): + r = gate.evaluate("success", "false", "true", "false", "failure", "", "", "", "", "", "") + assert not r.ok and "title" in r.message.lower() + + def test_gate_title_failure(): r = gate.evaluate("success", "false", "false", "false", "failure", "success", "success", "success", "success", "success", "success") @@ -78,7 +83,7 @@ def test_gate_test_suite_failure_fails_even_when_skip_validation(): def test_gate_test_suite_skipped_allows_skip_validation_pass(): - r = gate.evaluate("success", "false", "true", "false", "", "", "", "", "", "", "", + r = gate.evaluate("success", "false", "true", "false", "success", "", "", "", "", "", "", test_result="skipped") assert r.ok diff --git a/.github/pluginctl/tests/test_title.py b/.github/pluginctl/tests/test_title.py index eafda0e0..564bea25 100644 --- a/.github/pluginctl/tests/test_title.py +++ b/.github/pluginctl/tests/test_title.py @@ -6,6 +6,10 @@ def test_repo_prefix_zero_plugins_ok(): assert r.valid and r.feedback == "" +def test_repo_prefix_zero_plugins_canonical_form(): + assert check_title("[repo]: valid repository update", "alice", 0, []).valid + + def test_repo_prefix_zero_plugins_wrong(): r = check_title("[alice]: fix workflow", "alice", 0, []) assert not r.valid diff --git a/.github/pluginctl/tests/test_unlist.py b/.github/pluginctl/tests/test_unlist.py new file mode 100644 index 00000000..2fa60875 --- /dev/null +++ b/.github/pluginctl/tests/test_unlist.py @@ -0,0 +1,72 @@ +import json + +from pluginctl import label_catalog +from pluginctl.core import gh +from pluginctl.publish import unlist + + +def test_set_unlisted_updates_only_the_flag(tmp_path): + plugin_json = tmp_path / "plugin.json" + plugin_json.write_text(json.dumps({"name": "Demo", "version": "1.0.0"}), encoding="utf-8") + + assert unlist.set_unlisted(str(plugin_json)) + assert json.loads(plugin_json.read_text(encoding="utf-8")) == { + "name": "Demo", "version": "1.0.0", "unlisted": True, + } + assert not unlist.set_unlisted(str(plugin_json)) + + +def test_set_unlisted_rejects_non_object_json(tmp_path): + plugin_json = tmp_path / "plugin.json" + plugin_json.write_text("[]", encoding="utf-8") + + try: + unlist.set_unlisted(str(plugin_json)) + except ValueError as exc: + assert "JSON object" in str(exc) + else: + raise AssertionError("Expected a ValueError") + + +def test_open_pr_for_branch_returns_matching_pr(monkeypatch): + monkeypatch.setattr(gh, "api", lambda *args, **kwargs: json.dumps([ + {"html_url": "https://github.com/org/repo/pull/42"}, + ])) + + assert unlist._open_pr_for_branch("org/repo", "unlist/demo") == "https://github.com/org/repo/pull/42" + + +def test_run_rejects_closed_authorizing_issue(monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("UNLIST_PLUGIN", "demo") + monkeypatch.setenv("UNLIST_ISSUE", "42") + monkeypatch.setenv("SOURCE_BRANCH", "main") + monkeypatch.setattr(label_catalog, "ensure", lambda repository: True) + monkeypatch.setattr(gh, "api", lambda *args, **kwargs: "closed") + + assert unlist.run() == 1 + + +def test_run_rejects_invalid_plugin_slug_before_api_calls(monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("UNLIST_PLUGIN", "../plugin") + monkeypatch.setenv("UNLIST_ISSUE", "42") + monkeypatch.setenv("SOURCE_BRANCH", "main") + monkeypatch.setattr(label_catalog, "ensure", lambda repository: (_ for _ in ()).throw(AssertionError())) + + assert unlist.run() == 1 + + +def test_run_rejects_existing_unlisting_pr(monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("UNLIST_PLUGIN", "demo") + monkeypatch.setenv("UNLIST_ISSUE", "42") + monkeypatch.setenv("SOURCE_BRANCH", "main") + monkeypatch.setattr(label_catalog, "ensure", lambda repository: True) + monkeypatch.setattr(gh, "api", lambda *args, **kwargs: "open") + monkeypatch.setattr(unlist, "_open_pr_for_branch", lambda *args: "https://example.test/pr/42") + + assert unlist.run() == 1 diff --git a/.github/workflows/unlist-plugin.yml b/.github/workflows/unlist-plugin.yml new file mode 100644 index 00000000..d77a1e6f --- /dev/null +++ b/.github/workflows/unlist-plugin.yml @@ -0,0 +1,71 @@ +name: Unlist Plugin + +permissions: + contents: write + pull-requests: write + issues: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONPATH: .github/pluginctl/src + +on: + workflow_dispatch: + inputs: + plugin: + description: 'Plugin slug to unlist (for example, dispatcharr-exporter)' + type: string + required: true + issue_number: + description: 'Open Plugin Unlisting Request issue number that authorizes this action (for example, 42)' + type: string + required: true + +concurrency: + group: unlist-plugin-${{ inputs.plugin }} + cancel-in-progress: false + +jobs: + unlist: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout source branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: GitHub App token + id: token + uses: ./.github/actions/gh-app-token + with: + app-id: ${{ vars.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Open unlisting pull request + env: + GITHUB_TOKEN: ${{ steps.token.outputs.token }} + APP_SLUG: ${{ steps.token.outputs.app-slug }} + GITHUB_REPOSITORY: ${{ github.repository }} + UNLIST_PLUGIN: ${{ inputs.plugin }} + UNLIST_ISSUE: ${{ inputs.issue_number }} + SOURCE_BRANCH: ${{ github.ref_name }} + run: | + set -o pipefail + python -m pluginctl unlist 2>&1 | tee unlist.log + + - name: Generate job summary + if: always() + run: | + echo "## Plugin Unlisting Result" >> $GITHUB_STEP_SUMMARY + if [ -f unlist.log ]; then + if grep -q "Unlisting PR opened:" unlist.log; then + echo "✅ Unlisting pull request opened successfully" >> $GITHUB_STEP_SUMMARY + else + echo "❌ Unlisting failed. See logs for details." >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + tail -20 unlist.log >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/validate-plugin.yml b/.github/workflows/validate-plugin.yml index 1ad593cb..be3a8a05 100644 --- a/.github/workflows/validate-plugin.yml +++ b/.github/workflows/validate-plugin.yml @@ -198,7 +198,7 @@ jobs: # -------------------------------------------------------------------------- validate-title: needs: [detect-changes] - if: github.event.pull_request.draft == false && needs.detect-changes.result == 'success' && needs.detect-changes.outputs.close_pr == 'false' && needs.detect-changes.outputs.skip_validation != 'true' + if: github.event.pull_request.draft == false && needs.detect-changes.result == 'success' && needs.detect-changes.outputs.close_pr == 'false' runs-on: ubuntu-latest timeout-minutes: 2 outputs: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2cad88c8..cbaa0879 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,6 +105,8 @@ Do not use it for bugs, compatibility problems, support requests, or feature req To remove a published plugin version from the registry, [submit a Version Removal Request](https://github.com/Dispatcharr/Plugins/issues/new?template=version-removal-request.yml). +To remove a plugin from registry discovery without deleting its existing releases, [submit a Plugin Unlisting Request](https://github.com/Dispatcharr/Plugins/issues/new?template=plugin-unlisting-request.yml). A repository maintainer reviews the request and runs the unlisting workflow, which opens a pull request that sets `unlisted` to `true`. + ## Submit the pull request 1. Fork this repository and create a branch. @@ -167,3 +169,7 @@ The registry publishes a versioned ZIP and a `-latest` alias GitHub Release, cal ## Remove a published version To request removal of a version, open a [Version Removal Request](https://github.com/Dispatcharr/Plugins/issues/new?template=version-removal-request.yml). A maintainer runs the yank workflow. If the removed version was latest, the prior version becomes latest. If it was the only version, the plugin is removed from the registry. Existing downloads are unaffected. + +## Unlist a plugin + +To remove a plugin from registry discovery without removing its releases, open a [Plugin Unlisting Request](https://github.com/Dispatcharr/Plugins/issues/new?template=plugin-unlisting-request.yml). A repository maintainer can run the unlisting workflow for any plugin. It opens a pull request that sets `unlisted` to `true`; after that pull request merges and publishes, the plugin is omitted from the root manifest and releases README. Existing releases and per-plugin metadata remain available. diff --git a/docs/maintainer-operations.md b/docs/maintainer-operations.md index 43985125..fd2f3fd4 100644 --- a/docs/maintainer-operations.md +++ b/docs/maintainer-operations.md @@ -60,6 +60,7 @@ Every skipped candidate is marked for manual review. Successful automatic merges | --- | --- | --- | | **Publish Plugins** | Publishes plugin releases after changes reach `main`, rebuilds releases manually, and refreshes generated manifests every Sunday at 00:00 UTC. | A full rebuild is destructive. Manual rebuilds require `force_rebuild_confirm` to be `CONFIRM`. Target one plugin with `force_rebuild_plugin` whenever possible. The scheduled run can publish updated GitHub Release download totals when that manifest feature flag is enabled. | | **Yank Plugin Version** | Removes a published version following a Version Removal Request. | Requires the plugin slug, version, and authorizing issue number. It updates releases and closes the request. | +| **Unlist Plugin** | Removes a plugin from registry discovery following a Plugin Unlisting Request. | Requires the plugin slug and an open authorizing issue number. It opens a PR that sets `unlisted` to `true`; merging the PR triggers normal publication and closes the request. Existing releases remain available. | | **Run Migrations** | Performs maintainer-directed repository or release-branch migrations. | Review workflow inputs and logs before running it. | | **Update External README** | Synchronizes documentation to the configured external repository. | It is inactive until the optional external-README settings are configured. | From 30d1fc6f400ae69602b37c1750cfa1cfb550e42e Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Thu, 22 Oct 2026 19:34:14 -0400 Subject: [PATCH 2/6] fix(validation): trust maintainer operation PRs Allow bot-created yank and unlist PRs to bypass contributor ownership and title checks. Prefix removal request titles and rename the unlisting label. --- .../ISSUE_TEMPLATE/plugin-unlisting-request.yml | 3 ++- .../ISSUE_TEMPLATE/version-removal-request.yml | 1 + .../pluginctl/src/pluginctl/label_catalog.py | 2 +- .../pluginctl/src/pluginctl/publish/unlist.py | 2 +- .../pluginctl/src/pluginctl/validate/detect.py | 17 ++++++++++++----- .github/pluginctl/tests/test_detect.py | 16 ++++++++++++++++ .github/workflows/validate-plugin.yml | 3 ++- 7 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/plugin-unlisting-request.yml b/.github/ISSUE_TEMPLATE/plugin-unlisting-request.yml index 94535ba5..448b0b7a 100644 --- a/.github/ISSUE_TEMPLATE/plugin-unlisting-request.yml +++ b/.github/ISSUE_TEMPLATE/plugin-unlisting-request.yml @@ -1,6 +1,7 @@ name: Plugin Unlisting Request description: Request that a plugin be removed from registry discovery without removing its releases. -labels: ["Plugin Unlisting"] +title: "[Plugin Unlisting] " +labels: ["Unlisting"] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/version-removal-request.yml b/.github/ISSUE_TEMPLATE/version-removal-request.yml index 4197c3bb..ef4e719a 100644 --- a/.github/ISSUE_TEMPLATE/version-removal-request.yml +++ b/.github/ISSUE_TEMPLATE/version-removal-request.yml @@ -1,5 +1,6 @@ name: Version Removal Request description: Request that a specific published version of a plugin be removed from the registry. +title: "[Version Removal] " labels: ["Rollback"] body: - type: markdown diff --git a/.github/pluginctl/src/pluginctl/label_catalog.py b/.github/pluginctl/src/pluginctl/label_catalog.py index 12ff61f5..c58cadf4 100644 --- a/.github/pluginctl/src/pluginctl/label_catalog.py +++ b/.github/pluginctl/src/pluginctl/label_catalog.py @@ -27,7 +27,7 @@ class Label: Label("Manual Review Required", "CFD3D7", "Security findings, suppressions, or new plugins require maintainer review"), Label("QUARANTINE", "FFFF00", "ClamAV detected a potential threat in this PR"), Label("Rollback", "E11D48", "Version rollback opened by the yank workflow"), - Label("Plugin Unlisting", "B60205", "Maintainer request to remove a plugin from registry discovery"), + Label("Unlisting", "B60205", "Maintainer request to remove a plugin from registry discovery"), Label("Auto Merged", "333333", "PR was merged automatically after validation"), ) diff --git a/.github/pluginctl/src/pluginctl/publish/unlist.py b/.github/pluginctl/src/pluginctl/publish/unlist.py index 05476a06..a347bc60 100644 --- a/.github/pluginctl/src/pluginctl/publish/unlist.py +++ b/.github/pluginctl/src/pluginctl/publish/unlist.py @@ -110,7 +110,7 @@ def run() -> int: f"Existing releases remain available.\n\n" f"**Authorized by:** #{issue}\n\nCloses #{issue}") pr_url = gh.pr_create(repository, source_branch, branch, title, body, - labels=["Plugin Unlisting"]) + labels=["Unlisting"]) if not pr_url: actions.error("Unlisting branch was pushed, but the pull request could not be created.") return 1 diff --git a/.github/pluginctl/src/pluginctl/validate/detect.py b/.github/pluginctl/src/pluginctl/validate/detect.py index 053c5fcc..6f0639a7 100644 --- a/.github/pluginctl/src/pluginctl/validate/detect.py +++ b/.github/pluginctl/src/pluginctl/validate/detect.py @@ -114,13 +114,20 @@ def write_access() -> bool: cached_write_access = gh.has_write_access(owner, name, pr_author) return cached_write_access - # Bot-authored yank rollback PRs bypass plugin validation entirely. - if pr_author.endswith("[bot]") and head_ref.startswith("yank/"): - actions.log(f"Bot-authored yank rollback PR detected ({pr_author}, {head_ref}) " + # Bot-authored maintainer-operation PRs are created by privileged workflows + # after their authorizing issue has been checked. They bypass contributor + # ownership validation and title checks, which are for user-authored PRs. + automated_operation = next( + (name for name in ("yank", "unlist") if head_ref.startswith(f"{name}/")), + "", + ) + if pr_author.endswith("[bot]") and automated_operation: + actions.log(f"Bot-authored {automated_operation} PR detected ({pr_author}, {head_ref}) " "- skipping plugin validation.") actions.set_outputs(matrix="[]", plugin_count="0", close_pr="false", - close_reason="", skip_validation="true", - outside_violation="false", pub_key_changed="false", + close_reason="", skip_validation="true", + skip_title_validation="true", + outside_violation="false", pub_key_changed="false", has_new_plugin="false", has_updated_plugin="false", metadata_only="false") return 0 diff --git a/.github/pluginctl/tests/test_detect.py b/.github/pluginctl/tests/test_detect.py index 0029e6a7..c9023084 100644 --- a/.github/pluginctl/tests/test_detect.py +++ b/.github/pluginctl/tests/test_detect.py @@ -85,6 +85,22 @@ def test_unsafe_named_companion_folder_blocks_pr(tmp_path, monkeypatch): assert outputs["matrix"] == "[]" +@pytest.mark.parametrize("branch", ["yank/demo-1.2.3", "unlist/demo"]) +def test_bot_maintainer_operation_skips_contributor_validation(tmp_path, monkeypatch, branch): + monkeypatch.chdir(tmp_path) + out = tmp_path / "gh_output" + out.write_text("", encoding="utf-8") + monkeypatch.setenv("GITHUB_OUTPUT", str(out)) + + assert detect.run("dispatcharr-plugins-bot[bot]", "main", head_ref=branch) == 0 + + outputs = _outputs(out) + assert outputs["skip_validation"] == "true" + assert outputs["skip_title_validation"] == "true" + assert outputs["plugin_count"] == "0" + assert outputs["close_pr"] == "false" + + def test_metadata_only_update_accepts_approved_field_removal(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) pdir = tmp_path / "plugins" / "demo-plugin" diff --git a/.github/workflows/validate-plugin.yml b/.github/workflows/validate-plugin.yml index be3a8a05..a75361f9 100644 --- a/.github/workflows/validate-plugin.yml +++ b/.github/workflows/validate-plugin.yml @@ -113,6 +113,7 @@ jobs: outside_files: ${{ steps.detect.outputs.outside_files }} outside_violation: ${{ steps.detect.outputs.outside_violation }} skip_validation: ${{ steps.detect.outputs.skip_validation }} + skip_title_validation: ${{ steps.detect.outputs.skip_title_validation }} pub_key_changed: ${{ steps.detect.outputs.pub_key_changed }} has_new_plugin: ${{ steps.detect.outputs.has_new_plugin }} has_updated_plugin: ${{ steps.detect.outputs.has_updated_plugin }} @@ -198,7 +199,7 @@ jobs: # -------------------------------------------------------------------------- validate-title: needs: [detect-changes] - if: github.event.pull_request.draft == false && needs.detect-changes.result == 'success' && needs.detect-changes.outputs.close_pr == 'false' + if: github.event.pull_request.draft == false && needs.detect-changes.result == 'success' && needs.detect-changes.outputs.close_pr == 'false' && needs.detect-changes.outputs.skip_title_validation != 'true' runs-on: ubuntu-latest timeout-minutes: 2 outputs: From 56c090de299224c366d3abbdeec29e2291acc62c Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Thu, 22 Oct 2026 19:35:26 -0400 Subject: [PATCH 3/6] style(labels): align unlisting with rollback Use the rollback color for the Unlisting operational label. --- .github/pluginctl/src/pluginctl/label_catalog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pluginctl/src/pluginctl/label_catalog.py b/.github/pluginctl/src/pluginctl/label_catalog.py index c58cadf4..ef4cd40f 100644 --- a/.github/pluginctl/src/pluginctl/label_catalog.py +++ b/.github/pluginctl/src/pluginctl/label_catalog.py @@ -27,7 +27,7 @@ class Label: Label("Manual Review Required", "CFD3D7", "Security findings, suppressions, or new plugins require maintainer review"), Label("QUARANTINE", "FFFF00", "ClamAV detected a potential threat in this PR"), Label("Rollback", "E11D48", "Version rollback opened by the yank workflow"), - Label("Unlisting", "B60205", "Maintainer request to remove a plugin from registry discovery"), + Label("Unlisting", "E11D48", "Maintainer request to remove a plugin from registry discovery"), Label("Auto Merged", "333333", "PR was merged automatically after validation"), ) From 483d6382ea0add00c91ae89bd5b69c5adf3bb6b5 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Thu, 22 Oct 2026 19:38:45 -0400 Subject: [PATCH 4/6] fix(unlisting): reuse closed request branches Force-push with a lease after confirming no matching unlisting PR remains open, allowing a maintainer to rerun a request whose prior PR was closed. --- .github/pluginctl/src/pluginctl/publish/unlist.py | 7 ++++++- .github/pluginctl/tests/test_unlist.py | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/pluginctl/src/pluginctl/publish/unlist.py b/.github/pluginctl/src/pluginctl/publish/unlist.py index a347bc60..8465a8ee 100644 --- a/.github/pluginctl/src/pluginctl/publish/unlist.py +++ b/.github/pluginctl/src/pluginctl/publish/unlist.py @@ -43,6 +43,11 @@ def _open_pr_for_branch(repository: str, branch: str) -> str: return str(first.get("html_url") or "") +def _push_branch(remote: str, branch: str) -> None: + """Publish the branch, replacing a stale branch left by a closed request PR.""" + _git("push", "--force-with-lease", remote, branch) + + def run() -> int: repository = os.environ["GITHUB_REPOSITORY"] token = os.environ["GITHUB_TOKEN"] @@ -101,7 +106,7 @@ def run() -> int: _git("add", plugin_json) _git("commit", "-m", f"Unlist {plugin}\n\nRefs #{issue}") - _git("push", remote, branch) + _push_branch(remote, branch) title = f"[{plugin}]: Unlist plugin" body = (f"## Plugin Unlisting\n\n" diff --git a/.github/pluginctl/tests/test_unlist.py b/.github/pluginctl/tests/test_unlist.py index 2fa60875..dc02d569 100644 --- a/.github/pluginctl/tests/test_unlist.py +++ b/.github/pluginctl/tests/test_unlist.py @@ -36,6 +36,15 @@ def test_open_pr_for_branch_returns_matching_pr(monkeypatch): assert unlist._open_pr_for_branch("org/repo", "unlist/demo") == "https://github.com/org/repo/pull/42" +def test_push_branch_replaces_stale_automation_branch_with_a_lease(monkeypatch): + calls = [] + monkeypatch.setattr(unlist, "_git", lambda *args: calls.append(args)) + + unlist._push_branch("https://example.test/repo.git", "unlist/demo") + + assert calls == [("push", "--force-with-lease", "https://example.test/repo.git", "unlist/demo")] + + def test_run_rejects_closed_authorizing_issue(monkeypatch): monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") monkeypatch.setenv("GITHUB_TOKEN", "token") From 1f3e9518a3d63ffe26173b8e01ff48c38f3e6c26 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Thu, 22 Oct 2026 19:41:57 -0400 Subject: [PATCH 5/6] fix(yank): recover interrupted rollback operations Resume yanks whose release removal completed but rollback did not. Require evidence of an interrupted operation, reuse open rollback PRs, and safely replace stale closed branches. --- .../pluginctl/src/pluginctl/publish/yank.py | 84 ++++++++++++++++--- .github/pluginctl/tests/test_yank.py | 45 ++++++++++ 2 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 .github/pluginctl/tests/test_yank.py diff --git a/.github/pluginctl/src/pluginctl/publish/yank.py b/.github/pluginctl/src/pluginctl/publish/yank.py index f6d20c68..1511a98d 100644 --- a/.github/pluginctl/src/pluginctl/publish/yank.py +++ b/.github/pluginctl/src/pluginctl/publish/yank.py @@ -24,6 +24,41 @@ RELEASES_BRANCH = "releases" +def _open_pr_for_branch(repository: str, branch: str) -> str: + owner, _, _ = repository.partition("/") + if not owner: + return "" + pulls = gh.loads(gh.api( + f"repos/{repository}/pulls?state=open&head={owner}:{branch}", + )) + if not isinstance(pulls, list) or not pulls: + return "" + first = pulls[0] if isinstance(pulls[0], dict) else {} + return str(first.get("html_url") or "") + + +def _needs_rollback(release_exists: bool, is_latest: bool, source_version: str, + yanked_version: str) -> bool: + """Return whether source still advertises a version removed from releases.""" + return is_latest if release_exists else source_version == yanked_version + + +def _is_recoverable_absent_release(manifest_has_version: bool, rollback_branch_exists: bool, + release_commit_exists: bool) -> bool: + """Require evidence that a missing release belongs to an interrupted yank.""" + return manifest_has_version or rollback_branch_exists or release_commit_exists + + +def _manifest_has_version(path: str, version: str) -> bool: + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + return False + versions = (data.get("manifest") or {}).get("versions") or [] + return any(isinstance(entry, dict) and entry.get("version") == version for entry in versions) + + def run() -> int: repository = os.environ["GITHUB_REPOSITORY"] token = os.environ["GITHUB_TOKEN"] @@ -67,9 +102,24 @@ def run() -> int: release_tag = f"{plugin}-{version}" plugin_manifest = f"metadata/{plugin}/manifest.json" - if not gh.release_exists(release_tag, repository): - actions.error(f"GitHub Release {release_tag} not found. Nothing to yank.") - return 1 + release_exists = gh.release_exists(release_tag, repository) + if not release_exists: + rollback_branch = f"yank/{plugin}-{version}" + rollback_branch_exists = _git( + "ls-remote", "--exit-code", "--heads", remote, rollback_branch, + check=False, + ).returncode == 0 + release_commits = _git( + "log", "--fixed-strings", f"--grep=Yank {plugin} v{version}", + "--format=%H", RELEASES_BRANCH, check=False, + ) + release_commit_exists = release_commits.returncode == 0 and bool(release_commits.stdout.strip()) + if not _is_recoverable_absent_release( + _manifest_has_version(plugin_manifest, version), + rollback_branch_exists, release_commit_exists): + actions.error(f"GitHub Release {release_tag} not found. Nothing to yank.") + return 1 + actions.warning(f"GitHub Release {release_tag} is already absent; resuming yank recovery.") prefix = f"{plugin}-" remaining = [t for t in gh.release_list_tags(repository) @@ -94,23 +144,29 @@ def run() -> int: _git("fetch", "origin", source_branch) _git("checkout", f"origin/{source_branch}", "--", "plugins") source_type = "local" + source_version = "" pj = f"plugins/{plugin}/plugin.json" if os.path.isfile(pj): try: with open(pj, encoding="utf-8") as fh: - source_type = (json.load(fh).get("source_type") or "local") + source_data = json.load(fh) + source_type = source_data.get("source_type") or "local" + source_version = str(source_data.get("version") or "") except ValueError: source_type = "local" + needs_rollback = _needs_rollback(release_exists, is_latest, source_version, version) new_latest_version = "" if is_last_version: - actions.log(f"Last version - deleting all GitHub Releases for {plugin}.") - gh.release_delete(release_tag, repository) + if release_exists: + actions.log(f"Last version - deleting all GitHub Releases for {plugin}.") + gh.release_delete(release_tag, repository) subprocess.run(["rm", "-rf", f"metadata/{plugin}"], check=False) - else: + elif release_exists: actions.log(f"Deleting GitHub Release {release_tag}") gh.release_delete(release_tag, repository) - if is_latest: + if not is_last_version and needs_rollback: + if is_latest or not release_exists: bare = [t[len(prefix):] for t in remaining] ordered = sort_versions_desc(bare) new_latest_version = ordered[0] if ordered else "" @@ -131,13 +187,13 @@ def run() -> int: releases_commit = _commit_releases(remote, plugin, version, issue) rollback_pr_url = "" - if is_latest: + if needs_rollback: rollback_pr_url = _open_rollback_pr( repository, source_branch, remote, plugin, version, new_latest_version, is_last_version, source_type, issue) _finalize_issue(repository, plugin, version, issue, releases_commit, - is_latest, rollback_pr_url) + needs_rollback, rollback_pr_url) from ..integrations import webhooks webhooks.emit("plugin.yanked", webhooks.plugin_yanked(plugin, version, issue, rollback_pr_url or None)) @@ -163,9 +219,13 @@ def _commit_releases(remote, plugin, version, issue) -> str: def _open_rollback_pr(repository, source_branch, remote, plugin, version, - new_latest_version, is_last_version, source_type, issue) -> str: + new_latest_version, is_last_version, source_type, issue) -> str: actions.log("\n=== Opening rollback PR ===") rollback_branch = f"yank/{plugin}-{version}" + existing_pr = _open_pr_for_branch(repository, rollback_branch) + if existing_pr: + actions.log(f"Rollback PR already open: {existing_pr}") + return existing_pr _git("fetch", "origin", source_branch) _git("checkout", "-b", rollback_branch, f"origin/{source_branch}") @@ -187,7 +247,7 @@ def _open_rollback_pr(repository, source_branch, remote, plugin, version, f"**Authorized by:** #{issue}\n\n{source_note}\n\nCloses #{issue}") _git("commit", "-m", pr_title) - _git("push", remote, rollback_branch) + _git("push", "--force-with-lease", remote, rollback_branch) url = gh.pr_create(repository, source_branch, rollback_branch, pr_title, pr_body, labels=["Rollback"]) or "" actions.log(f"Rollback PR opened: {rollback_branch} -> {source_branch} ({url})") diff --git a/.github/pluginctl/tests/test_yank.py b/.github/pluginctl/tests/test_yank.py new file mode 100644 index 00000000..5d0061eb --- /dev/null +++ b/.github/pluginctl/tests/test_yank.py @@ -0,0 +1,45 @@ +import json + +from pluginctl.core import gh +from pluginctl.publish import yank + + +def test_needs_rollback_for_latest_existing_release(): + assert yank._needs_rollback(True, True, "2.0.0", "2.0.0") + + +def test_needs_rollback_skips_non_latest_existing_release(): + assert not yank._needs_rollback(True, False, "2.0.0", "1.0.0") + + +def test_needs_rollback_resumes_when_source_still_advertises_absent_release(): + assert yank._needs_rollback(False, False, "2.0.0", "2.0.0") + + +def test_needs_rollback_does_not_repeat_completed_recovery(): + assert not yank._needs_rollback(False, False, "1.0.0", "2.0.0") + + +def test_absent_release_requires_interrupted_yank_evidence(): + assert not yank._is_recoverable_absent_release(False, False, False) + assert yank._is_recoverable_absent_release(True, False, False) + assert yank._is_recoverable_absent_release(False, True, False) + assert yank._is_recoverable_absent_release(False, False, True) + + +def test_manifest_has_version(tmp_path): + path = tmp_path / "manifest.json" + path.write_text(json.dumps({"manifest": {"versions": [ + {"version": "2.0.0"}, {"version": "1.0.0"}, + ]}}), encoding="utf-8") + + assert yank._manifest_has_version(str(path), "2.0.0") + assert not yank._manifest_has_version(str(path), "3.0.0") + + +def test_open_pr_for_branch_returns_matching_pr(monkeypatch): + monkeypatch.setattr(gh, "api", lambda *args, **kwargs: json.dumps([ + {"html_url": "https://github.com/org/repo/pull/42"}, + ])) + + assert yank._open_pr_for_branch("org/repo", "yank/demo-2.0.0") == "https://github.com/org/repo/pull/42" From f9eefe105bde2586e9891ef9134b4b148e7c10cf Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Thu, 22 Oct 2026 19:45:23 -0400 Subject: [PATCH 6/6] fix(automation): use explicit branch push leases Resolve stale branch SHAs before replacing unlisting or yank branches so retries work from fresh clones without overwriting concurrent updates. --- .github/pluginctl/src/pluginctl/core/git.py | 17 ++++++++ .../pluginctl/src/pluginctl/publish/unlist.py | 4 +- .../pluginctl/src/pluginctl/publish/yank.py | 4 +- .github/pluginctl/tests/test_git.py | 40 +++++++++++++++++++ .github/pluginctl/tests/test_unlist.py | 4 +- 5 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 .github/pluginctl/tests/test_git.py diff --git a/.github/pluginctl/src/pluginctl/core/git.py b/.github/pluginctl/src/pluginctl/core/git.py index 9c8f233b..d29d4781 100644 --- a/.github/pluginctl/src/pluginctl/core/git.py +++ b/.github/pluginctl/src/pluginctl/core/git.py @@ -30,6 +30,23 @@ def configure_identity(app_slug: str) -> None: run("config", "user.email", email) +def push_branch_with_lease(remote: str, branch: str) -> None: + """Push ``HEAD`` to ``branch``, safely replacing a stale remote branch. + + A fresh clone does not necessarily configure a tracking ref for a branch + created by prior automation. Resolve its remote SHA explicitly so the lease + still rejects a concurrent update. + """ + existing = run("ls-remote", "--heads", remote, branch, check=False) + destination = f"HEAD:refs/heads/{branch}" + if existing.returncode == 0 and existing.stdout.strip(): + expected_sha = existing.stdout.split()[0] + run("push", f"--force-with-lease=refs/heads/{branch}:{expected_sha}", + remote, destination) + return + run("push", remote, destination) + + def merge_base(a: str, b: str) -> str: return run("merge-base", a, b).stdout.strip() diff --git a/.github/pluginctl/src/pluginctl/publish/unlist.py b/.github/pluginctl/src/pluginctl/publish/unlist.py index 8465a8ee..b069fa1c 100644 --- a/.github/pluginctl/src/pluginctl/publish/unlist.py +++ b/.github/pluginctl/src/pluginctl/publish/unlist.py @@ -10,7 +10,7 @@ from .. import label_catalog from ..core import actions, gh -from ..core.git import configure_identity, run as _git +from ..core.git import configure_identity, push_branch_with_lease, run as _git _SAFE_PLUGIN_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") @@ -45,7 +45,7 @@ def _open_pr_for_branch(repository: str, branch: str) -> str: def _push_branch(remote: str, branch: str) -> None: """Publish the branch, replacing a stale branch left by a closed request PR.""" - _git("push", "--force-with-lease", remote, branch) + push_branch_with_lease(remote, branch) def run() -> int: diff --git a/.github/pluginctl/src/pluginctl/publish/yank.py b/.github/pluginctl/src/pluginctl/publish/yank.py index 1511a98d..ff5988f4 100644 --- a/.github/pluginctl/src/pluginctl/publish/yank.py +++ b/.github/pluginctl/src/pluginctl/publish/yank.py @@ -17,7 +17,7 @@ from .. import label_catalog from ..core import actions, gh -from ..core.git import configure_identity, run as _git +from ..core.git import configure_identity, push_branch_with_lease, run as _git from ..core.version import sort_versions_desc from . import manifest, readmes @@ -247,7 +247,7 @@ def _open_rollback_pr(repository, source_branch, remote, plugin, version, f"**Authorized by:** #{issue}\n\n{source_note}\n\nCloses #{issue}") _git("commit", "-m", pr_title) - _git("push", "--force-with-lease", remote, rollback_branch) + push_branch_with_lease(remote, rollback_branch) url = gh.pr_create(repository, source_branch, rollback_branch, pr_title, pr_body, labels=["Rollback"]) or "" actions.log(f"Rollback PR opened: {rollback_branch} -> {source_branch} ({url})") diff --git a/.github/pluginctl/tests/test_git.py b/.github/pluginctl/tests/test_git.py new file mode 100644 index 00000000..e67b0ff9 --- /dev/null +++ b/.github/pluginctl/tests/test_git.py @@ -0,0 +1,40 @@ +from types import SimpleNamespace + +from pluginctl.core import git + + +def test_push_branch_with_lease_uses_remote_sha(monkeypatch): + calls = [] + + def fake_run(*args, **kwargs): + calls.append((args, kwargs)) + if args[0] == "ls-remote": + return SimpleNamespace(returncode=0, stdout="abc123\trefs/heads/unlist/demo\n") + return SimpleNamespace(returncode=0, stdout="") + + monkeypatch.setattr(git, "run", fake_run) + + git.push_branch_with_lease("https://example.test/repo.git", "unlist/demo") + + assert calls == [ + (("ls-remote", "--heads", "https://example.test/repo.git", "unlist/demo"), {"check": False}), + (("push", "--force-with-lease=refs/heads/unlist/demo:abc123", + "https://example.test/repo.git", "HEAD:refs/heads/unlist/demo"), {}), + ] + + +def test_push_branch_with_lease_creates_missing_branch(monkeypatch): + calls = [] + + def fake_run(*args, **kwargs): + calls.append((args, kwargs)) + return SimpleNamespace(returncode=0, stdout="") + + monkeypatch.setattr(git, "run", fake_run) + + git.push_branch_with_lease("https://example.test/repo.git", "unlist/demo") + + assert calls == [ + (("ls-remote", "--heads", "https://example.test/repo.git", "unlist/demo"), {"check": False}), + (("push", "https://example.test/repo.git", "HEAD:refs/heads/unlist/demo"), {}), + ] diff --git a/.github/pluginctl/tests/test_unlist.py b/.github/pluginctl/tests/test_unlist.py index dc02d569..8fee59d0 100644 --- a/.github/pluginctl/tests/test_unlist.py +++ b/.github/pluginctl/tests/test_unlist.py @@ -38,11 +38,11 @@ def test_open_pr_for_branch_returns_matching_pr(monkeypatch): def test_push_branch_replaces_stale_automation_branch_with_a_lease(monkeypatch): calls = [] - monkeypatch.setattr(unlist, "_git", lambda *args: calls.append(args)) + monkeypatch.setattr(unlist, "push_branch_with_lease", lambda *args: calls.append(args)) unlist._push_branch("https://example.test/repo.git", "unlist/demo") - assert calls == [("push", "--force-with-lease", "https://example.test/repo.git", "unlist/demo")] + assert calls == [("https://example.test/repo.git", "unlist/demo")] def test_run_rejects_closed_authorizing_issue(monkeypatch):