kestrel-feature-github 0.1.1__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/.gitignore +1 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/PKG-INFO +1 -1
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/kestrel_feature_github/SKILL.md +6 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/kestrel_feature_github/client.py +363 -4
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/kestrel_feature_github/feature.py +618 -1
- kestrel_feature_github-0.2.0/kestrel_feature_github/stale_work.py +122 -0
- kestrel_feature_github-0.2.0/kestrel_feature_github/stalled_sweep_source.py +93 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/pyproject.toml +1 -1
- kestrel_feature_github-0.2.0/tests/test_fleet_sweep_source.py +129 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/tests/test_github_feature.py +82 -0
- kestrel_feature_github-0.2.0/tests/test_stale_work.py +172 -0
- kestrel_feature_github-0.2.0/tests/test_write_tools.py +507 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/.github/workflows/ci.yml +0 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/.github/workflows/publish.yml +0 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/AGENTS.md +0 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/LICENSE +0 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/README.md +0 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/kestrel_feature_github/__init__.py +0 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/kestrel_feature_github/ast_analyzer.py +0 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/kestrel_feature_github/cache.py +0 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/kestrel_feature_github/models.py +0 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/tests/__init__.py +0 -0
- {kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/tests/conftest.py +0 -0
{kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/kestrel_feature_github/SKILL.md
RENAMED
|
@@ -52,6 +52,12 @@
|
|
|
52
52
|
- **Description**: Get information about the agent's own source repository
|
|
53
53
|
- **Category**: data_access
|
|
54
54
|
|
|
55
|
+
### get_github_repo_info
|
|
56
|
+
- **Description**: Get metadata for any accessible GitHub repository, including visibility, default branch, description, and open issue count
|
|
57
|
+
- **Category**: data_access
|
|
58
|
+
- **Parameters**:
|
|
59
|
+
- `repo` (string, optional): Repository in 'owner/repo' format, or 'self' (default: self)
|
|
60
|
+
|
|
55
61
|
### list_source_components
|
|
56
62
|
- **Description**: List all feature components in the agent's source code
|
|
57
63
|
- **Category**: data_access
|
{kestrel_feature_github-0.1.1 → kestrel_feature_github-0.2.0}/kestrel_feature_github/client.py
RENAMED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import base64
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
|
-
from typing import Optional
|
|
5
|
+
from typing import Any, Optional
|
|
6
6
|
from urllib.parse import quote
|
|
7
7
|
|
|
8
8
|
import httpx
|
|
@@ -112,11 +112,26 @@ class GitHubClient:
|
|
|
112
112
|
self._client = None
|
|
113
113
|
|
|
114
114
|
def _parse_repo(self, repo: str) -> tuple[str, str]:
|
|
115
|
-
"""Parse owner/repo string.
|
|
115
|
+
"""Parse and URL-encode an ``owner/repo`` string.
|
|
116
|
+
|
|
117
|
+
The segments are only ever interpolated into REST paths, so encode them
|
|
118
|
+
(``safe=""`` also encodes ``/``): a crafted repo containing ``/``,
|
|
119
|
+
``..``, or query/fragment characters can't inject into or traverse the
|
|
120
|
+
API path (F348). Legit ``owner/repo`` characters are all unreserved, so
|
|
121
|
+
they pass through unchanged.
|
|
122
|
+
"""
|
|
116
123
|
if "/" not in repo:
|
|
117
124
|
raise GitHubClientError(f"Invalid repo format: {repo}. Expected 'owner/repo'.")
|
|
118
|
-
|
|
119
|
-
|
|
125
|
+
owner, repo_name = repo.split("/", 1)
|
|
126
|
+
# Reject empty or dot-only segments: '.'/'..' are unreserved so ``quote``
|
|
127
|
+
# leaves them intact and httpx collapses them in the path, which would
|
|
128
|
+
# still allow REST traversal (e.g. '../meta', 'owner/..').
|
|
129
|
+
for seg in (owner, repo_name):
|
|
130
|
+
if seg in ("", ".", ".."):
|
|
131
|
+
raise GitHubClientError(
|
|
132
|
+
f"Invalid repo format: {repo}. Expected 'owner/repo'."
|
|
133
|
+
)
|
|
134
|
+
return quote(owner, safe=""), quote(repo_name, safe="")
|
|
120
135
|
|
|
121
136
|
async def get_file_content(
|
|
122
137
|
self,
|
|
@@ -430,6 +445,8 @@ class GitHubClient:
|
|
|
430
445
|
state: str = "open",
|
|
431
446
|
labels: Optional[list[str]] = None,
|
|
432
447
|
per_page: int = 30,
|
|
448
|
+
sort: Optional[str] = None,
|
|
449
|
+
direction: Optional[str] = None,
|
|
433
450
|
) -> list[dict]:
|
|
434
451
|
"""List issues in a repository.
|
|
435
452
|
|
|
@@ -438,6 +455,9 @@ class GitHubClient:
|
|
|
438
455
|
state: Issue state filter ('open', 'closed', 'all')
|
|
439
456
|
labels: Optional list of label names to filter by
|
|
440
457
|
per_page: Number of results per page (max 100)
|
|
458
|
+
sort: Optional sort field ('created', 'updated', 'comments').
|
|
459
|
+
GitHub defaults to 'created' descending.
|
|
460
|
+
direction: Optional sort direction ('asc', 'desc').
|
|
441
461
|
|
|
442
462
|
Returns:
|
|
443
463
|
List of issue dicts (excludes pull requests)
|
|
@@ -454,6 +474,10 @@ class GitHubClient:
|
|
|
454
474
|
}
|
|
455
475
|
if labels:
|
|
456
476
|
params["labels"] = ",".join(labels)
|
|
477
|
+
if sort:
|
|
478
|
+
params["sort"] = sort
|
|
479
|
+
if direction:
|
|
480
|
+
params["direction"] = direction
|
|
457
481
|
|
|
458
482
|
response = await client.get(
|
|
459
483
|
f"/repos/{owner}/{repo_name}/issues",
|
|
@@ -471,6 +495,92 @@ class GitHubClient:
|
|
|
471
495
|
items = response.json()
|
|
472
496
|
return [i for i in items if "pull_request" not in i]
|
|
473
497
|
|
|
498
|
+
async def list_pull_requests(
|
|
499
|
+
self,
|
|
500
|
+
repo: str,
|
|
501
|
+
state: str = "open",
|
|
502
|
+
per_page: int = 100,
|
|
503
|
+
) -> list[dict]:
|
|
504
|
+
"""List pull requests in a repository (oldest-updated first).
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
repo: Repository in 'owner/repo' format
|
|
508
|
+
state: PR state filter ('open', 'closed', 'all')
|
|
509
|
+
per_page: Number of results per page (max 100)
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
List of pull-request dicts.
|
|
513
|
+
|
|
514
|
+
Raises:
|
|
515
|
+
GitHubClientError: If request fails
|
|
516
|
+
"""
|
|
517
|
+
owner, repo_name = self._parse_repo(repo)
|
|
518
|
+
client = await self._get_client()
|
|
519
|
+
|
|
520
|
+
response = await client.get(
|
|
521
|
+
f"/repos/{owner}/{repo_name}/pulls",
|
|
522
|
+
params={
|
|
523
|
+
"state": state,
|
|
524
|
+
"per_page": min(per_page, 100),
|
|
525
|
+
"sort": "updated",
|
|
526
|
+
"direction": "asc",
|
|
527
|
+
},
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
if response.status_code == 404:
|
|
531
|
+
raise GitHubClientError(f"Repository not found: {repo}", 404)
|
|
532
|
+
elif response.status_code == 403:
|
|
533
|
+
raise GitHubClientError("Rate limited or access denied", 403)
|
|
534
|
+
elif response.status_code != 200:
|
|
535
|
+
raise GitHubClientError(f"GitHub API error: {response.text}", response.status_code)
|
|
536
|
+
|
|
537
|
+
return response.json()
|
|
538
|
+
|
|
539
|
+
async def list_workflow_runs(
|
|
540
|
+
self,
|
|
541
|
+
repo: str,
|
|
542
|
+
branch: Optional[str] = None,
|
|
543
|
+
per_page: int = 1,
|
|
544
|
+
status: str = "completed",
|
|
545
|
+
) -> list[dict]:
|
|
546
|
+
"""List Actions workflow runs for a repo (newest first).
|
|
547
|
+
|
|
548
|
+
Args:
|
|
549
|
+
repo: Repository in 'owner/repo' format
|
|
550
|
+
branch: Optional branch filter (e.g. the default branch)
|
|
551
|
+
per_page: Number of runs to return (max 100)
|
|
552
|
+
status: Run status filter (default 'completed')
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
List of workflow-run dicts (the ``workflow_runs`` array).
|
|
556
|
+
|
|
557
|
+
Raises:
|
|
558
|
+
GitHubClientError: If request fails
|
|
559
|
+
"""
|
|
560
|
+
owner, repo_name = self._parse_repo(repo)
|
|
561
|
+
client = await self._get_client()
|
|
562
|
+
|
|
563
|
+
params: dict[str, Any] = {
|
|
564
|
+
"per_page": min(per_page, 100),
|
|
565
|
+
"status": status,
|
|
566
|
+
}
|
|
567
|
+
if branch:
|
|
568
|
+
params["branch"] = branch
|
|
569
|
+
|
|
570
|
+
response = await client.get(
|
|
571
|
+
f"/repos/{owner}/{repo_name}/actions/runs",
|
|
572
|
+
params=params,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
if response.status_code == 404:
|
|
576
|
+
raise GitHubClientError(f"Repository not found: {repo}", 404)
|
|
577
|
+
elif response.status_code == 403:
|
|
578
|
+
raise GitHubClientError("Rate limited or access denied", 403)
|
|
579
|
+
elif response.status_code != 200:
|
|
580
|
+
raise GitHubClientError(f"GitHub API error: {response.text}", response.status_code)
|
|
581
|
+
|
|
582
|
+
return response.json().get("workflow_runs", [])
|
|
583
|
+
|
|
474
584
|
async def get_issue(
|
|
475
585
|
self,
|
|
476
586
|
repo: str,
|
|
@@ -539,3 +649,252 @@ class GitHubClient:
|
|
|
539
649
|
raise GitHubClientError(f"GitHub API error: {response.text}", response.status_code)
|
|
540
650
|
|
|
541
651
|
return response.json()
|
|
652
|
+
|
|
653
|
+
# ============================================================
|
|
654
|
+
# Write API — feeds the @tool surface in feature.py.
|
|
655
|
+
# Each method is a thin wrapper around a single REST endpoint;
|
|
656
|
+
# error mapping mirrors the read-side pattern.
|
|
657
|
+
# ============================================================
|
|
658
|
+
|
|
659
|
+
async def add_issue_comment(
|
|
660
|
+
self,
|
|
661
|
+
repo: str,
|
|
662
|
+
issue_number: int,
|
|
663
|
+
body: str,
|
|
664
|
+
) -> dict:
|
|
665
|
+
"""Post a comment on an issue or pull request.
|
|
666
|
+
|
|
667
|
+
Note: GitHub uses the issues endpoint for both issue and PR comments —
|
|
668
|
+
PR review comments (on specific lines) are a separate endpoint not
|
|
669
|
+
wrapped here.
|
|
670
|
+
"""
|
|
671
|
+
owner, repo_name = self._parse_repo(repo)
|
|
672
|
+
client = await self._get_client()
|
|
673
|
+
response = await client.post(
|
|
674
|
+
f"/repos/{owner}/{repo_name}/issues/{issue_number}/comments",
|
|
675
|
+
json={"body": body},
|
|
676
|
+
)
|
|
677
|
+
if response.status_code == 201:
|
|
678
|
+
return response.json()
|
|
679
|
+
if response.status_code == 404:
|
|
680
|
+
raise GitHubClientError(f"Issue #{issue_number} not found in {repo}", 404)
|
|
681
|
+
if response.status_code == 403:
|
|
682
|
+
raise GitHubClientError("Rate limited or access denied", 403)
|
|
683
|
+
raise GitHubClientError(
|
|
684
|
+
f"Comment creation failed: {response.text}", response.status_code,
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
async def add_labels(
|
|
688
|
+
self,
|
|
689
|
+
repo: str,
|
|
690
|
+
issue_number: int,
|
|
691
|
+
labels: list[str],
|
|
692
|
+
) -> list[dict]:
|
|
693
|
+
"""Add labels to an issue or PR (additive — existing labels preserved)."""
|
|
694
|
+
owner, repo_name = self._parse_repo(repo)
|
|
695
|
+
client = await self._get_client()
|
|
696
|
+
response = await client.post(
|
|
697
|
+
f"/repos/{owner}/{repo_name}/issues/{issue_number}/labels",
|
|
698
|
+
json={"labels": labels},
|
|
699
|
+
)
|
|
700
|
+
if response.status_code == 200:
|
|
701
|
+
return response.json()
|
|
702
|
+
if response.status_code == 404:
|
|
703
|
+
raise GitHubClientError(f"Issue #{issue_number} not found in {repo}", 404)
|
|
704
|
+
if response.status_code == 403:
|
|
705
|
+
raise GitHubClientError("Rate limited or access denied", 403)
|
|
706
|
+
raise GitHubClientError(
|
|
707
|
+
f"Label add failed: {response.text}", response.status_code,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
async def remove_label(
|
|
711
|
+
self,
|
|
712
|
+
repo: str,
|
|
713
|
+
issue_number: int,
|
|
714
|
+
label: str,
|
|
715
|
+
) -> None:
|
|
716
|
+
"""Remove a single label from an issue or PR.
|
|
717
|
+
|
|
718
|
+
Returns None on success. Specifically idempotent ONLY for
|
|
719
|
+
"label-not-on-issue" — GitHub's response body for that case
|
|
720
|
+
contains "Label does not exist". A 404 with any other body
|
|
721
|
+
(missing issue, missing repo, insufficient access) raises
|
|
722
|
+
``GitHubClientError`` so the caller doesn't get a false-positive
|
|
723
|
+
success for an entirely different failure mode.
|
|
724
|
+
"""
|
|
725
|
+
owner, repo_name = self._parse_repo(repo)
|
|
726
|
+
client = await self._get_client()
|
|
727
|
+
# GitHub requires the label path-segment to be URL-encoded.
|
|
728
|
+
from urllib.parse import quote
|
|
729
|
+
response = await client.delete(
|
|
730
|
+
f"/repos/{owner}/{repo_name}/issues/{issue_number}/labels/{quote(label, safe='')}",
|
|
731
|
+
)
|
|
732
|
+
if response.status_code in (200, 204):
|
|
733
|
+
return None
|
|
734
|
+
if response.status_code == 404:
|
|
735
|
+
# Disambiguate label-not-on-issue from issue/repo-missing. GitHub
|
|
736
|
+
# returns the same status code for both; only the body differs.
|
|
737
|
+
body = (response.text or "").lower()
|
|
738
|
+
if "label does not exist" in body:
|
|
739
|
+
return None # Idempotent success — label wasn't on the issue.
|
|
740
|
+
raise GitHubClientError(
|
|
741
|
+
f"Issue or repo not found, or label endpoint unreachable: "
|
|
742
|
+
f"{response.text}",
|
|
743
|
+
404,
|
|
744
|
+
)
|
|
745
|
+
if response.status_code == 403:
|
|
746
|
+
raise GitHubClientError("Rate limited or access denied", 403)
|
|
747
|
+
raise GitHubClientError(
|
|
748
|
+
f"Label remove failed: {response.text}", response.status_code,
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
async def update_issue(
|
|
752
|
+
self,
|
|
753
|
+
repo: str,
|
|
754
|
+
issue_number: int,
|
|
755
|
+
*,
|
|
756
|
+
state: Optional[str] = None,
|
|
757
|
+
state_reason: Optional[str] = None,
|
|
758
|
+
title: Optional[str] = None,
|
|
759
|
+
body: Optional[str] = None,
|
|
760
|
+
) -> dict:
|
|
761
|
+
"""Patch an issue. Used for close/reopen (via state) and edits.
|
|
762
|
+
|
|
763
|
+
``state`` is ``open`` or ``closed``. ``state_reason`` may be
|
|
764
|
+
``completed``, ``not_planned``, or ``reopened`` (per GitHub's
|
|
765
|
+
documented values). Pass only the fields you intend to change.
|
|
766
|
+
"""
|
|
767
|
+
owner, repo_name = self._parse_repo(repo)
|
|
768
|
+
client = await self._get_client()
|
|
769
|
+
payload: dict = {}
|
|
770
|
+
if state is not None:
|
|
771
|
+
payload["state"] = state
|
|
772
|
+
if state_reason is not None:
|
|
773
|
+
payload["state_reason"] = state_reason
|
|
774
|
+
if title is not None:
|
|
775
|
+
payload["title"] = title
|
|
776
|
+
if body is not None:
|
|
777
|
+
payload["body"] = body
|
|
778
|
+
if not payload:
|
|
779
|
+
raise GitHubClientError(
|
|
780
|
+
"update_issue called with no fields to update", 400,
|
|
781
|
+
)
|
|
782
|
+
response = await client.patch(
|
|
783
|
+
f"/repos/{owner}/{repo_name}/issues/{issue_number}",
|
|
784
|
+
json=payload,
|
|
785
|
+
)
|
|
786
|
+
if response.status_code == 200:
|
|
787
|
+
return response.json()
|
|
788
|
+
if response.status_code == 404:
|
|
789
|
+
raise GitHubClientError(f"Issue #{issue_number} not found in {repo}", 404)
|
|
790
|
+
if response.status_code == 403:
|
|
791
|
+
raise GitHubClientError("Rate limited or access denied", 403)
|
|
792
|
+
if response.status_code == 422:
|
|
793
|
+
raise GitHubClientError(
|
|
794
|
+
f"Issue update validation failed: {response.text}", 422,
|
|
795
|
+
)
|
|
796
|
+
raise GitHubClientError(
|
|
797
|
+
f"Issue update failed: {response.text}", response.status_code,
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
async def create_pull_request(
|
|
801
|
+
self,
|
|
802
|
+
repo: str,
|
|
803
|
+
*,
|
|
804
|
+
title: str,
|
|
805
|
+
head: str,
|
|
806
|
+
base: str,
|
|
807
|
+
body: str = "",
|
|
808
|
+
draft: bool = False,
|
|
809
|
+
maintainer_can_modify: bool = True,
|
|
810
|
+
) -> dict:
|
|
811
|
+
"""Open a pull request.
|
|
812
|
+
|
|
813
|
+
``head`` is the branch carrying the changes (use ``owner:branch`` for
|
|
814
|
+
cross-fork PRs; same-repo can be just the branch name). ``base`` is
|
|
815
|
+
the target branch on this repo.
|
|
816
|
+
"""
|
|
817
|
+
owner, repo_name = self._parse_repo(repo)
|
|
818
|
+
client = await self._get_client()
|
|
819
|
+
response = await client.post(
|
|
820
|
+
f"/repos/{owner}/{repo_name}/pulls",
|
|
821
|
+
json={
|
|
822
|
+
"title": title,
|
|
823
|
+
"head": head,
|
|
824
|
+
"base": base,
|
|
825
|
+
"body": body,
|
|
826
|
+
"draft": draft,
|
|
827
|
+
"maintainer_can_modify": maintainer_can_modify,
|
|
828
|
+
},
|
|
829
|
+
)
|
|
830
|
+
if response.status_code == 201:
|
|
831
|
+
return response.json()
|
|
832
|
+
if response.status_code == 403:
|
|
833
|
+
raise GitHubClientError("Rate limited or access denied", 403)
|
|
834
|
+
if response.status_code == 404:
|
|
835
|
+
raise GitHubClientError(f"Repository not found or no permission: {repo}", 404)
|
|
836
|
+
if response.status_code == 422:
|
|
837
|
+
# 422 covers "no diff", "head already PR'd", "branch missing", etc.
|
|
838
|
+
raise GitHubClientError(
|
|
839
|
+
f"PR creation validation failed: {response.text}", 422,
|
|
840
|
+
)
|
|
841
|
+
raise GitHubClientError(
|
|
842
|
+
f"PR creation failed: {response.text}", response.status_code,
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
async def merge_pull_request(
|
|
846
|
+
self,
|
|
847
|
+
repo: str,
|
|
848
|
+
pull_number: int,
|
|
849
|
+
*,
|
|
850
|
+
commit_title: Optional[str] = None,
|
|
851
|
+
commit_message: Optional[str] = None,
|
|
852
|
+
merge_method: str = "squash",
|
|
853
|
+
sha: Optional[str] = None,
|
|
854
|
+
) -> dict:
|
|
855
|
+
"""Merge a pull request.
|
|
856
|
+
|
|
857
|
+
``merge_method`` is ``merge``, ``squash``, or ``rebase``.
|
|
858
|
+
If ``sha`` is set, the merge will fail unless the PR HEAD matches —
|
|
859
|
+
callers should pass it when they need strict head-stability.
|
|
860
|
+
"""
|
|
861
|
+
if merge_method not in ("merge", "squash", "rebase"):
|
|
862
|
+
raise GitHubClientError(
|
|
863
|
+
f"merge_method must be merge|squash|rebase, got {merge_method!r}",
|
|
864
|
+
400,
|
|
865
|
+
)
|
|
866
|
+
owner, repo_name = self._parse_repo(repo)
|
|
867
|
+
client = await self._get_client()
|
|
868
|
+
payload: dict = {"merge_method": merge_method}
|
|
869
|
+
if commit_title is not None:
|
|
870
|
+
payload["commit_title"] = commit_title
|
|
871
|
+
if commit_message is not None:
|
|
872
|
+
payload["commit_message"] = commit_message
|
|
873
|
+
if sha is not None:
|
|
874
|
+
payload["sha"] = sha
|
|
875
|
+
response = await client.put(
|
|
876
|
+
f"/repos/{owner}/{repo_name}/pulls/{pull_number}/merge",
|
|
877
|
+
json=payload,
|
|
878
|
+
)
|
|
879
|
+
if response.status_code == 200:
|
|
880
|
+
return response.json()
|
|
881
|
+
if response.status_code == 405:
|
|
882
|
+
raise GitHubClientError(
|
|
883
|
+
f"PR #{pull_number} is not mergeable: {response.text}", 405,
|
|
884
|
+
)
|
|
885
|
+
if response.status_code == 409:
|
|
886
|
+
raise GitHubClientError(
|
|
887
|
+
f"PR #{pull_number} head SHA mismatch (someone pushed): {response.text}",
|
|
888
|
+
409,
|
|
889
|
+
)
|
|
890
|
+
if response.status_code == 403:
|
|
891
|
+
raise GitHubClientError("Rate limited or access denied", 403)
|
|
892
|
+
if response.status_code == 404:
|
|
893
|
+
raise GitHubClientError(f"PR #{pull_number} not found in {repo}", 404)
|
|
894
|
+
if response.status_code == 422:
|
|
895
|
+
raise GitHubClientError(
|
|
896
|
+
f"Merge validation failed: {response.text}", 422,
|
|
897
|
+
)
|
|
898
|
+
raise GitHubClientError(
|
|
899
|
+
f"PR merge failed: {response.text}", response.status_code,
|
|
900
|
+
)
|