From e61f54acd116f1dcce4e38202135ba5b465304a9 Mon Sep 17 00:00:00 2001 From: arnavk23 Date: Sat, 18 Apr 2026 00:25:31 +0530 Subject: [PATCH 1/6] Handle resource-not-accessible 403 as non-internal --- tagbot/action/repo.py | 39 ++++++++++++++++++++++++++++++--------- test/action/test_repo.py | 26 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/tagbot/action/repo.py b/tagbot/action/repo.py index 63449ac7..d29b1c03 100644 --- a/tagbot/action/repo.py +++ b/tagbot/action/repo.py @@ -1500,7 +1500,7 @@ def _release_already_exists(exc: GithubException) -> bool: if e.status == 422 and _release_already_exists(e): logger.info(f"Release for tag {version_tag} already exists, skipping") return - elif e.status == 403 and "resource not accessible" in str(e).lower(): + elif self._is_resource_not_accessible_error(e): logger.error( "Release creation blocked: token lacks required permissions. " "Use a PAT with contents:write (and workflows if tagging " @@ -1514,6 +1514,17 @@ def _release_already_exists(exc: GithubException) -> bool: raise logger.info(f"GitHub release {version_tag} created successfully") + @staticmethod + def _is_resource_not_accessible_error(exc: GithubException) -> bool: + """Identify GitHub's known 403 integration access error variant.""" + if exc.status != 403: + return False + data = getattr(exc, "data", {}) or {} + message = str(data.get("message", "")) + if "resource not accessible" in message.lower(): + return True + return "resource not accessible" in str(exc).lower() + def _check_rate_limit(self) -> None: """Check and log GitHub API rate limit status.""" try: @@ -1555,14 +1566,24 @@ def handle_error(self, e: Exception, *, raise_abort: bool = True) -> None: allowed = False elif e.status == 403: self._check_rate_limit() - logger.error( - "GitHub returned a 403 error. This may indicate: " - "1. Rate limiting - check the rate limit status above, " - "2. Insufficient permissions - verify your token & repo access, " - "3. Resource not accessible - see setup documentation" - ) - internal = False - allowed = False + if self._is_resource_not_accessible_error(e): + logger.warning( + "GitHub returned 403 Resource not accessible by integration; " + "skipping internal error reporting for this known permissions " + "scenario." + ) + internal = False + allowed = True + else: + logger.error( + "GitHub returned a 403 error. This may indicate: " + "1. Rate limiting - check the rate limit status above, " + "2. Insufficient permissions - verify your token & repo " + "access, " + "3. Resource not accessible - see setup documentation" + ) + internal = False + allowed = False if not allowed: if internal: logger.error("TagBot experienced an unexpected internal failure") diff --git a/test/action/test_repo.py b/test/action/test_repo.py index 91b4b03d..22cc7bd7 100644 --- a/test/action/test_repo.py +++ b/test/action/test_repo.py @@ -1291,6 +1291,32 @@ def test_handle_error_403_checks_rate_limit(mock_logger, format_exc): assert any("403" in str(call) for call in mock_logger.error.call_args_list) +@patch("traceback.format_exc", return_value="ahh") +@patch("tagbot.action.repo.logger") +def test_handle_error_403_resource_not_accessible_not_reported( + mock_logger, format_exc +): + r = _repo() + r._report_error = Mock() + r._check_rate_limit = Mock() + + # Known permissions issue should not be treated as an internal failure. + r.handle_error( + GithubException( + 403, + {"message": "Resource not accessible by integration"}, + {}, + ) + ) + + r._check_rate_limit.assert_called_once() + r._report_error.assert_not_called() + assert any( + "Resource not accessible by integration" in str(call) + for call in mock_logger.warning.call_args_list + ) + + def test_commit_sha_of_version(): r = _repo() r._Repo__registry_path = "" From 2f1a5bd4f698a29cd23d387f101729998a453c4a Mon Sep 17 00:00:00 2001 From: arnavk23 Date: Sat, 18 Apr 2026 00:52:13 +0530 Subject: [PATCH 2/6] Keep resource-not-accessible 403 fatal --- tagbot/action/repo.py | 28 ++++++++++++++++------------ test/action/test_backfilling.py | 5 +++++ test/action/test_repo.py | 25 +++++++++++++++---------- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/tagbot/action/repo.py b/tagbot/action/repo.py index d29b1c03..9d510ae0 100644 --- a/tagbot/action/repo.py +++ b/tagbot/action/repo.py @@ -13,6 +13,7 @@ import pexpect import requests import toml +from pexpect.popen_spawn import PopenSpawn from base64 import b64decode from datetime import datetime, timedelta @@ -66,6 +67,7 @@ UnknownObjectExceptions = (UnknownObjectException, GitlabUnknown) RequestException = requests.RequestException +PEXPECT_SPAWN = getattr(pexpect, "spawn", PopenSpawn) # Maximum number of PRs to check when looking for registry PR # This prevents excessive API calls on large registries @@ -1363,7 +1365,7 @@ def configure_ssh(self, key: str, password: Optional[str], repo: str = "") -> No for k, v in re.findall(r"\s*(.+)=(.+?);", proc.stdout): logger.debug(f"Setting environment variable {k}={v}") os.environ[k] = v - child = pexpect.spawn(f"ssh-add {priv}") + child = PEXPECT_SPAWN(f"ssh-add {priv}") child.expect("Enter passphrase") child.sendline(password) child.expect("Identity added") @@ -1520,7 +1522,7 @@ def _is_resource_not_accessible_error(exc: GithubException) -> bool: if exc.status != 403: return False data = getattr(exc, "data", {}) or {} - message = str(data.get("message", "")) + message = str(data.get("message", "")) if isinstance(data, dict) else str(data) if "resource not accessible" in message.lower(): return True return "resource not accessible" in str(exc).lower() @@ -1539,31 +1541,34 @@ def _check_rate_limit(self) -> None: def handle_error(self, e: Exception, *, raise_abort: bool = True) -> None: """Handle an unexpected error.""" - allowed = False internal = True + report_error = True + fatal = True trace = self._sanitize(traceback.format_exc()) if isinstance(e, Abort): # Abort is raised for characterized failures (e.g., git command failures) # Don't report as "unexpected internal failure" internal = False - allowed = False + report_error = False + fatal = False elif isinstance(e, RequestException): logger.warning("TagBot encountered a likely transient HTTP exception") logger.info(trace) - allowed = True + report_error = False + fatal = False elif isinstance(e, GithubException): logger.info(e.headers) if 500 <= e.status < 600: logger.warning("GitHub returned a 5xx error code") logger.info(trace) - allowed = True + report_error = False + fatal = False elif e.status == 401: logger.error( "GitHub returned 401 Bad credentials. Verify that your token " "is valid and has access to the repository and registry." ) internal = False - allowed = False elif e.status == 403: self._check_rate_limit() if self._is_resource_not_accessible_error(e): @@ -1573,7 +1578,7 @@ def handle_error(self, e: Exception, *, raise_abort: bool = True) -> None: "scenario." ) internal = False - allowed = True + report_error = False else: logger.error( "GitHub returned a 403 error. This may indicate: " @@ -1583,8 +1588,7 @@ def handle_error(self, e: Exception, *, raise_abort: bool = True) -> None: "3. Resource not accessible - see setup documentation" ) internal = False - allowed = False - if not allowed: + if report_error: if internal: logger.error("TagBot experienced an unexpected internal failure") logger.info(trace) @@ -1593,8 +1597,8 @@ def handle_error(self, e: Exception, *, raise_abort: bool = True) -> None: except Exception: logger.error("Issue reporting failed") logger.info(traceback.format_exc()) - if raise_abort: - raise Abort("Cannot continue due to internal failure") + if fatal and raise_abort: + raise Abort("Cannot continue due to internal failure") def commit_sha_of_version(self, version: str) -> Optional[str]: """Get the commit SHA from a registered version.""" diff --git a/test/action/test_backfilling.py b/test/action/test_backfilling.py index 52d39489..aa867654 100644 --- a/test/action/test_backfilling.py +++ b/test/action/test_backfilling.py @@ -9,7 +9,9 @@ from tagbot.action.repo import Repo, _metrics +@patch("tagbot.action.repo.Github") def _repo( + mock_github, *, repo="", registry="", @@ -29,6 +31,9 @@ def _repo( subdir=None, tag_prefix=None, ): + mock_gh_instance = Mock() + mock_github.return_value = mock_gh_instance + mock_gh_instance.get_repo.return_value = Mock() return Repo( repo=repo, registry=registry, diff --git a/test/action/test_repo.py b/test/action/test_repo.py index 22cc7bd7..31a7ee15 100644 --- a/test/action/test_repo.py +++ b/test/action/test_repo.py @@ -125,7 +125,9 @@ def test_project_subdir(): ) assert r._project("name") == "FooBar" assert r._project("uuid") == "abc-def" - r._repo.get_contents.assert_called_once_with("path/to/FooBar.jl/Project.toml") + r._repo.get_contents.assert_called_once_with( + os.path.join("path/to/FooBar.jl", "Project.toml") + ) r._repo.get_contents.side_effect = UnknownObjectException(404, "???", {}) r._Repo__project = None with pytest.raises(InvalidProject): @@ -883,7 +885,7 @@ def test_create_dispatch_event(): @patch("tagbot.action.repo.mkstemp", side_effect=[(0, "abc"), (0, "xyz")] * 3) @patch("os.chmod") @patch("subprocess.run") -@patch("pexpect.spawn") +@patch("tagbot.action.repo.PEXPECT_SPAWN") def test_configure_ssh(spawn, run, chmod, mkstemp): r = _repo(github="gh.com", repo="foo") r._repo = Mock(ssh_url="sshurl") @@ -1280,7 +1282,8 @@ def test_handle_error(mock_logger, format_exc): @patch("traceback.format_exc", return_value="ahh") @patch("tagbot.action.repo.logger") def test_handle_error_403_checks_rate_limit(mock_logger, format_exc): - r = _repo() + r = Repo.__new__(Repo) + r._token = "" r._report_error = Mock() r._check_rate_limit = Mock() try: @@ -1296,18 +1299,20 @@ def test_handle_error_403_checks_rate_limit(mock_logger, format_exc): def test_handle_error_403_resource_not_accessible_not_reported( mock_logger, format_exc ): - r = _repo() + r = Repo.__new__(Repo) + r._token = "" r._report_error = Mock() r._check_rate_limit = Mock() # Known permissions issue should not be treated as an internal failure. - r.handle_error( - GithubException( - 403, - {"message": "Resource not accessible by integration"}, - {}, + with pytest.raises(Abort): + r.handle_error( + GithubException( + 403, + {"message": "Resource not accessible by integration"}, + {}, + ) ) - ) r._check_rate_limit.assert_called_once() r._report_error.assert_not_called() From 478124330a2d0caf39381b418f64a509b50db73c Mon Sep 17 00:00:00 2001 From: arnavk23 Date: Sat, 18 Apr 2026 01:06:28 +0530 Subject: [PATCH 3/6] update repo.py --- tagbot/action/repo.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/tagbot/action/repo.py b/tagbot/action/repo.py index 688427d7..a77e925b 100644 --- a/tagbot/action/repo.py +++ b/tagbot/action/repo.py @@ -62,8 +62,10 @@ if GitlabUnknown is not None: UnknownObjectExceptions = (UnknownObjectException, GitlabUnknown) -RequestException = requests.RequestException -PEXPECT_SPAWN = getattr(pexpect, "spawn", PopenSpawn) +try: + from requests import RequestException +except ImportError: + RequestException = OSError # type: ignore[assignment,misc] # Maximum number of PRs to check when looking for registry PR # This prevents excessive API calls on large registries @@ -1485,7 +1487,9 @@ def configure_ssh(self, key: str, password: Optional[str], repo: str = "") -> No for k, v in re.findall(r"\s*(.+)=(.+?);", proc.stdout): logger.debug(f"Setting environment variable {k}={v}") os.environ[k] = v - child = PEXPECT_SPAWN(f"ssh-add {priv}") + import pexpect + + child = pexpect.spawn(f"ssh-add {priv}") child.expect("Enter passphrase") child.sendline(password) child.expect("Identity added") @@ -1703,15 +1707,15 @@ def handle_error(self, e: Exception, *, raise_abort: bool = True) -> None: internal = False report_error = False else: - logger.error( - "GitHub returned a 403 error. This may indicate: " - "1. Rate limiting - check the rate limit status above, " - "2. Insufficient permissions - verify your token & repo " - "access, " - "3. Resource not accessible - see setup documentation" - ) - internal = False - if report_error: + logger.error( + "GitHub returned a 403 error. This may indicate: " + "1. Rate limiting - check the rate limit status above, " + "2. Insufficient permissions - verify your token & repo access, " + "3. Resource not accessible - see setup documentation" + ) + internal = False + allowed = False + if not allowed: if internal: logger.error("TagBot experienced an unexpected internal failure") logger.info(trace) @@ -1720,8 +1724,8 @@ def handle_error(self, e: Exception, *, raise_abort: bool = True) -> None: except Exception: logger.error("Issue reporting failed") logger.info(traceback.format_exc()) - if fatal and raise_abort: - raise Abort("Cannot continue due to internal failure") + if fatal and raise_abort: + raise Abort("Cannot continue due to internal failure") def commit_sha_of_version(self, version: str) -> Optional[str]: """Get the commit SHA from a registered version.""" From 4076e786036d366d1c80d112d3aac03a21115212 Mon Sep 17 00:00:00 2001 From: arnavk23 Date: Sat, 18 Apr 2026 01:11:22 +0530 Subject: [PATCH 4/6] update test_repo.py --- test/action/test_repo.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/action/test_repo.py b/test/action/test_repo.py index c8208f4d..66c7099b 100644 --- a/test/action/test_repo.py +++ b/test/action/test_repo.py @@ -153,9 +153,7 @@ def test_project_subdir(): ) assert r._project("name") == "FooBar" assert r._project("uuid") == "abc-def" - r._repo.get_contents.assert_called_once_with( - os.path.join("path/to/FooBar.jl", "Project.toml") - ) + r._repo.get_contents.assert_called_once_with("path/to/FooBar.jl", "Project.toml") r._repo.get_contents.side_effect = UnknownObjectException(404, "???", {}) r._Repo__project = None with pytest.raises(InvalidProject): @@ -976,7 +974,7 @@ def test_create_dispatch_event(): @patch("tagbot.action.repo.mkstemp", side_effect=[(0, "abc"), (0, "xyz")] * 3) @patch("os.chmod") @patch("subprocess.run") -@patch("tagbot.action.repo.PEXPECT_SPAWN") +@patch("pexpect.spawn") def test_configure_ssh(spawn, run, chmod, mkstemp): r = _repo(github="gh.com", repo="foo") r._repo = Mock(ssh_url="sshurl") @@ -1375,8 +1373,9 @@ def test_handle_error(mock_logger, format_exc): @patch("traceback.format_exc", return_value="ahh") @patch("tagbot.action.repo.logger") def test_handle_error_403_checks_rate_limit(mock_logger, format_exc): - r = Repo.__new__(Repo) + r = _repo() r._token = "" + r._registry_token = "" r._report_error = Mock() r._check_rate_limit = Mock() try: @@ -1392,8 +1391,9 @@ def test_handle_error_403_checks_rate_limit(mock_logger, format_exc): def test_handle_error_403_resource_not_accessible_not_reported( mock_logger, format_exc ): - r = Repo.__new__(Repo) + r = _repo() r._token = "" + r._registry_token = "" r._report_error = Mock() r._check_rate_limit = Mock() From 4ea81238ec93d2698e9a30fba593f79962a0b60f Mon Sep 17 00:00:00 2001 From: arnavk23 Date: Sat, 18 Apr 2026 02:28:15 +0530 Subject: [PATCH 5/6] test_configure_ssh now patches pexpect.spawn with create=True. --- tagbot/action/repo.py | 23 +++++++++++------------ test/action/test_repo.py | 6 ++++-- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/tagbot/action/repo.py b/tagbot/action/repo.py index a77e925b..edbfdc81 100644 --- a/tagbot/action/repo.py +++ b/tagbot/action/repo.py @@ -10,7 +10,6 @@ from importlib.metadata import version as pkg_version, PackageNotFoundError import toml -from pexpect.popen_spawn import PopenSpawn from base64 import b64decode from datetime import datetime, timedelta @@ -1707,15 +1706,15 @@ def handle_error(self, e: Exception, *, raise_abort: bool = True) -> None: internal = False report_error = False else: - logger.error( - "GitHub returned a 403 error. This may indicate: " - "1. Rate limiting - check the rate limit status above, " - "2. Insufficient permissions - verify your token & repo access, " - "3. Resource not accessible - see setup documentation" - ) - internal = False - allowed = False - if not allowed: + logger.error( + "GitHub returned a 403 error. This may indicate: " + "1. Rate limiting - check the rate limit status above, " + "2. Insufficient permissions - verify your token & repo " + "access, " + "3. Resource not accessible - see setup documentation" + ) + internal = False + if report_error: if internal: logger.error("TagBot experienced an unexpected internal failure") logger.info(trace) @@ -1724,8 +1723,8 @@ def handle_error(self, e: Exception, *, raise_abort: bool = True) -> None: except Exception: logger.error("Issue reporting failed") logger.info(traceback.format_exc()) - if fatal and raise_abort: - raise Abort("Cannot continue due to internal failure") + if fatal and raise_abort: + raise Abort("Cannot continue due to internal failure") def commit_sha_of_version(self, version: str) -> Optional[str]: """Get the commit SHA from a registered version.""" diff --git a/test/action/test_repo.py b/test/action/test_repo.py index 66c7099b..6d554c46 100644 --- a/test/action/test_repo.py +++ b/test/action/test_repo.py @@ -153,7 +153,9 @@ def test_project_subdir(): ) assert r._project("name") == "FooBar" assert r._project("uuid") == "abc-def" - r._repo.get_contents.assert_called_once_with("path/to/FooBar.jl", "Project.toml") + r._repo.get_contents.assert_called_once_with( + os.path.join("path/to/FooBar.jl", "Project.toml") + ) r._repo.get_contents.side_effect = UnknownObjectException(404, "???", {}) r._Repo__project = None with pytest.raises(InvalidProject): @@ -974,7 +976,7 @@ def test_create_dispatch_event(): @patch("tagbot.action.repo.mkstemp", side_effect=[(0, "abc"), (0, "xyz")] * 3) @patch("os.chmod") @patch("subprocess.run") -@patch("pexpect.spawn") +@patch("pexpect.spawn", create=True) def test_configure_ssh(spawn, run, chmod, mkstemp): r = _repo(github="gh.com", repo="foo") r._repo = Mock(ssh_url="sshurl") From 3a9b558115b4d7ade0fb3eaae9516b40579d8beb Mon Sep 17 00:00:00 2001 From: arnavk23 Date: Sat, 18 Apr 2026 02:49:37 +0530 Subject: [PATCH 6/6] black --- test/action/test_repo.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/action/test_repo.py b/test/action/test_repo.py index 6d554c46..2323fc41 100644 --- a/test/action/test_repo.py +++ b/test/action/test_repo.py @@ -1390,9 +1390,7 @@ def test_handle_error_403_checks_rate_limit(mock_logger, format_exc): @patch("traceback.format_exc", return_value="ahh") @patch("tagbot.action.repo.logger") -def test_handle_error_403_resource_not_accessible_not_reported( - mock_logger, format_exc -): +def test_handle_error_403_resource_not_accessible_not_reported(mock_logger, format_exc): r = _repo() r._token = "" r._registry_token = ""