kestrel-feature-github 0.1.0__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.
Files changed (25) hide show
  1. kestrel_feature_github-0.2.0/.gitignore +4 -0
  2. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/PKG-INFO +1 -1
  3. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/kestrel_feature_github/SKILL.md +6 -0
  4. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/kestrel_feature_github/client.py +363 -4
  5. kestrel_feature_github-0.2.0/kestrel_feature_github/feature.py +1444 -0
  6. kestrel_feature_github-0.2.0/kestrel_feature_github/stale_work.py +122 -0
  7. kestrel_feature_github-0.2.0/kestrel_feature_github/stalled_sweep_source.py +93 -0
  8. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/pyproject.toml +1 -1
  9. kestrel_feature_github-0.2.0/tests/test_fleet_sweep_source.py +129 -0
  10. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/tests/test_github_feature.py +114 -2
  11. kestrel_feature_github-0.2.0/tests/test_stale_work.py +172 -0
  12. kestrel_feature_github-0.2.0/tests/test_write_tools.py +507 -0
  13. kestrel_feature_github-0.1.0/.gitignore +0 -2
  14. kestrel_feature_github-0.1.0/kestrel_feature_github/feature.py +0 -734
  15. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/.github/workflows/ci.yml +0 -0
  16. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/.github/workflows/publish.yml +0 -0
  17. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/AGENTS.md +0 -0
  18. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/LICENSE +0 -0
  19. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/README.md +0 -0
  20. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/kestrel_feature_github/__init__.py +0 -0
  21. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/kestrel_feature_github/ast_analyzer.py +0 -0
  22. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/kestrel_feature_github/cache.py +0 -0
  23. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/kestrel_feature_github/models.py +0 -0
  24. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/tests/__init__.py +0 -0
  25. {kestrel_feature_github-0.1.0 → kestrel_feature_github-0.2.0}/tests/conftest.py +0 -0
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ *.pyc
3
+ agent_data/
4
+ uv.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kestrel-feature-github
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Kestrel GitHub integration feature
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -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
@@ -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
- parts = repo.split("/", 1)
119
- return parts[0], parts[1]
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
+ )