sase-github 0.2.2__tar.gz → 0.2.4__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 (41) hide show
  1. sase_github-0.2.4/.release-please-manifest.json +3 -0
  2. {sase_github-0.2.2 → sase_github-0.2.4}/CHANGELOG.md +14 -0
  3. {sase_github-0.2.2 → sase_github-0.2.4}/PKG-INFO +2 -2
  4. {sase_github-0.2.2 → sase_github-0.2.4}/pyproject.toml +1 -1
  5. {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/plugin.py +125 -11
  6. {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/workspace_plugin.py +6 -1
  7. {sase_github-0.2.2 → sase_github-0.2.4}/tests/test_github_issues.py +6 -1
  8. sase_github-0.2.4/tests/test_github_pull_requests.py +200 -0
  9. sase_github-0.2.2/.release-please-manifest.json +0 -3
  10. {sase_github-0.2.2 → sase_github-0.2.4}/.github/workflows/ci.yml +0 -0
  11. {sase_github-0.2.2 → sase_github-0.2.4}/.github/workflows/pr-title.yml +0 -0
  12. {sase_github-0.2.2 → sase_github-0.2.4}/.github/workflows/publish.yml +0 -0
  13. {sase_github-0.2.2 → sase_github-0.2.4}/.sase_beads/beads.db +0 -0
  14. {sase_github-0.2.2 → sase_github-0.2.4}/.sase_beads/config.json +0 -0
  15. {sase_github-0.2.2 → sase_github-0.2.4}/.sase_beads/issues.jsonl +0 -0
  16. {sase_github-0.2.2 → sase_github-0.2.4}/CLAUDE.md +0 -0
  17. {sase_github-0.2.2 → sase_github-0.2.4}/Justfile +0 -0
  18. {sase_github-0.2.2 → sase_github-0.2.4}/LICENSE +0 -0
  19. {sase_github-0.2.2 → sase_github-0.2.4}/README.md +0 -0
  20. {sase_github-0.2.2 → sase_github-0.2.4}/docs/architecture.md +0 -0
  21. {sase_github-0.2.2 → sase_github-0.2.4}/docs/configuration.md +0 -0
  22. {sase_github-0.2.2 → sase_github-0.2.4}/docs/xprompts.md +0 -0
  23. {sase_github-0.2.2 → sase_github-0.2.4}/release-please-config.json +0 -0
  24. {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/__init__.py +0 -0
  25. {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/config.py +0 -0
  26. {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/default_config.yml +0 -0
  27. {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/errors.py +0 -0
  28. {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/scripts/__init__.py +0 -0
  29. {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/scripts/gh_setup.py +0 -0
  30. {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/scripts/new_pr_desc_get_context.py +0 -0
  31. {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/xprompts/gh.yml +0 -0
  32. {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/xprompts/new_pr_desc.yml +0 -0
  33. {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/xprompts/pr_diff.yml +0 -0
  34. {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/xprompts/prdd.yml +0 -0
  35. {sase_github-0.2.2 → sase_github-0.2.4}/tests/__init__.py +0 -0
  36. {sase_github-0.2.2 → sase_github-0.2.4}/tests/test_ci_install_contract.py +0 -0
  37. {sase_github-0.2.2 → sase_github-0.2.4}/tests/test_config.py +0 -0
  38. {sase_github-0.2.2 → sase_github-0.2.4}/tests/test_github_plugin.py +0 -0
  39. {sase_github-0.2.2 → sase_github-0.2.4}/tests/test_submit_with_recorded_pr.py +0 -0
  40. {sase_github-0.2.2 → sase_github-0.2.4}/tests/test_workspace_plugin.py +0 -0
  41. {sase_github-0.2.2 → sase_github-0.2.4}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.2.4"
3
+ }
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.4](https://github.com/sase-org/sase-github/compare/v0.2.3...v0.2.4) (2026-08-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * implement vcs_list_pull_requests over gh pr list ([2b0d6a8](https://github.com/sase-org/sase-github/commit/2b0d6a8d6bc3ecd4cd9e7ebdeb012978cddb1181))
9
+
10
+ ## [0.2.3](https://github.com/sase-org/sase-github/compare/v0.2.2...v0.2.3) (2026-08-09)
11
+
12
+
13
+ ### Documentation
14
+
15
+ * mark github plugin patch compatibility boundaries ([e79c33b](https://github.com/sase-org/sase-github/commit/e79c33b231d72605957f793fb6a338c910913af0))
16
+
3
17
  ## [0.2.2](https://github.com/sase-org/sase-github/compare/v0.2.1...v0.2.2) (2026-08-08)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: sase-github
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: GitHub VCS plugin for sase
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sase-github"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "GitHub VCS plugin for sase"
9
9
  requires-python = ">=3.12"
10
10
  license = "MIT"
@@ -10,7 +10,14 @@ import subprocess
10
10
  from collections.abc import Sequence
11
11
  from typing import Any, cast
12
12
 
13
- from sase.vcs_provider import IssueListState, IssueState, IssueWire
13
+ from sase.vcs_provider import (
14
+ IssueListState,
15
+ IssueState,
16
+ IssueWire,
17
+ PullRequestListState,
18
+ PullRequestState,
19
+ PullRequestWire,
20
+ )
14
21
  from sase.vcs_provider._hookspec import hookimpl
15
22
  from sase.vcs_provider._types import CommandOutput
16
23
  from sase.vcs_provider.plugins._git_common import GitCommon
@@ -22,7 +29,12 @@ from sase_github.errors import (
22
29
  )
23
30
 
24
31
  _ISSUE_JSON_FIELDS = (
25
- "number,title,state,body,labels,assignees,author,createdAt,updatedAt,url,comments"
32
+ "id,number,title,state,body,labels,assignees,author,createdAt,updatedAt,url,"
33
+ "comments"
34
+ )
35
+ _PR_JSON_FIELDS = (
36
+ "id,number,title,state,body,isDraft,author,headRefName,baseRefName,createdAt,"
37
+ "updatedAt,closedAt,mergedAt,url"
26
38
  )
27
39
  _UNBOUNDED_ISSUE_LIMIT = 1_000_000
28
40
  _AUTH_ERROR_MARKERS = (
@@ -140,6 +152,56 @@ def _issue_from_json(payload: object) -> IssueWire:
140
152
  updated_at=_string_field(payload, "updatedAt"),
141
153
  url=_string_field(payload, "url"),
142
154
  comment_count=_comment_count(payload.get("comments")),
155
+ provider_id=_string_field(payload, "id"),
156
+ )
157
+
158
+
159
+ def _pull_request_from_json(payload: object) -> PullRequestWire:
160
+ """Normalize one ``gh pr --json`` object into :class:`PullRequestWire`.
161
+
162
+ GitHub reports a merged PR's raw ``state`` as ``MERGED``, distinct from
163
+ ``CLOSED``; that collapses into our two-valued ``state`` as ``"closed"``,
164
+ with ``merged_at`` left populated as the signal that tells the two apart.
165
+ """
166
+ if not isinstance(payload, dict):
167
+ raise ValueError("pull request must be an object")
168
+
169
+ number = payload.get("number")
170
+ title = payload.get("title")
171
+ raw_state = payload.get("state")
172
+ is_draft = payload.get("isDraft")
173
+ if not isinstance(number, int) or isinstance(number, bool):
174
+ raise ValueError("number must be an integer")
175
+ if not isinstance(title, str):
176
+ raise ValueError("title must be a string")
177
+ if not isinstance(raw_state, str) or raw_state.casefold() not in {
178
+ "open",
179
+ "closed",
180
+ "merged",
181
+ }:
182
+ raise ValueError("state must be open, closed, or merged")
183
+ if not isinstance(is_draft, bool):
184
+ raise ValueError("isDraft must be a boolean")
185
+
186
+ state = cast(
187
+ PullRequestState,
188
+ "closed" if raw_state.casefold() == "merged" else raw_state.casefold(),
189
+ )
190
+ return PullRequestWire(
191
+ number=number,
192
+ title=title,
193
+ state=state,
194
+ provider_id=_string_field(payload, "id"),
195
+ url=_string_field(payload, "url"),
196
+ body=_string_field(payload, "body"),
197
+ is_draft=is_draft,
198
+ author=_login(payload.get("author"), "author"),
199
+ head_ref=_string_field(payload, "headRefName"),
200
+ base_ref=_string_field(payload, "baseRefName"),
201
+ created_at=_string_field(payload, "createdAt"),
202
+ updated_at=_string_field(payload, "updatedAt"),
203
+ closed_at=_string_field(payload, "closedAt"),
204
+ merged_at=_string_field(payload, "mergedAt"),
143
205
  )
144
206
 
145
207
 
@@ -182,14 +244,14 @@ class GitHubPlugin(GitCommon):
182
244
 
183
245
  # --- Optional issue-tracker operations ---
184
246
 
185
- def _run_issue_command(self, cmd: list[str], cwd: str, operation: str) -> str:
247
+ def _run_gh_command(self, cmd: list[str], cwd: str, operation: str) -> str:
186
248
  out = self._run(cmd, cwd)
187
249
  if not out.success:
188
250
  raise _command_error(operation, out)
189
251
  return out.stdout
190
252
 
191
- def _run_issue_json(self, cmd: list[str], cwd: str, operation: str) -> object:
192
- raw = self._run_issue_command(cmd, cwd, operation)
253
+ def _run_gh_json(self, cmd: list[str], cwd: str, operation: str) -> object:
254
+ raw = self._run_gh_command(cmd, cwd, operation)
193
255
  try:
194
256
  return json.loads(raw)
195
257
  except json.JSONDecodeError as exc:
@@ -200,7 +262,7 @@ class GitHubPlugin(GitCommon):
200
262
 
201
263
  def _view_issue(self, issue_ref: int | str, cwd: str) -> IssueWire:
202
264
  operation = "gh issue view"
203
- payload = self._run_issue_json(
265
+ payload = self._run_gh_json(
204
266
  [
205
267
  "gh",
206
268
  "issue",
@@ -229,7 +291,7 @@ class GitHubPlugin(GitCommon):
229
291
  ) -> list[IssueWire]:
230
292
  """List GitHub issues and normalize ``gh`` JSON output."""
231
293
  operation = "gh issue list"
232
- payload = self._run_issue_json(
294
+ payload = self._run_gh_json(
233
295
  [
234
296
  "gh",
235
297
  "issue",
@@ -272,7 +334,7 @@ class GitHubPlugin(GitCommon):
272
334
  cmd = ["gh", "issue", "create", "--title", title, "--body", body]
273
335
  for label in _ordered_unique(labels):
274
336
  cmd.extend(("--label", label))
275
- issue_ref = self._run_issue_command(cmd, cwd, operation).strip()
337
+ issue_ref = self._run_gh_command(cmd, cwd, operation).strip()
276
338
  if not issue_ref:
277
339
  raise GitHubIssueError(operation, "gh returned no created issue URL")
278
340
  return self._view_issue(issue_ref, cwd)
@@ -305,11 +367,11 @@ class GitHubPlugin(GitCommon):
305
367
  if label not in current_set:
306
368
  edit_cmd.extend(("--add-label", label))
307
369
  if len(edit_cmd) > 4:
308
- self._run_issue_command(edit_cmd, cwd, "gh issue edit")
370
+ self._run_gh_command(edit_cmd, cwd, "gh issue edit")
309
371
 
310
372
  if state is not None and state != current.state:
311
373
  state_operation = f"gh issue {'close' if state == 'closed' else 'reopen'}"
312
- self._run_issue_command(
374
+ self._run_gh_command(
313
375
  ["gh", "issue", state_operation.rsplit(" ", 1)[-1], str(number)],
314
376
  cwd,
315
377
  state_operation,
@@ -323,7 +385,7 @@ class GitHubPlugin(GitCommon):
323
385
  def vcs_get_issue_url(self, number: int, cwd: str) -> str:
324
386
  """Return the issue URL while requesting no other issue fields."""
325
387
  operation = "gh issue view"
326
- url = self._run_issue_command(
388
+ url = self._run_gh_command(
327
389
  [
328
390
  "gh",
329
391
  "issue",
@@ -341,6 +403,58 @@ class GitHubPlugin(GitCommon):
341
403
  raise GitHubIssueError(operation, "gh returned no issue URL")
342
404
  return url
343
405
 
406
+ # --- Optional pull-request operations ---
407
+
408
+ @hookimpl
409
+ def vcs_list_pull_requests(
410
+ self,
411
+ cwd: str,
412
+ state: PullRequestListState,
413
+ limit: int,
414
+ ) -> list[PullRequestWire]:
415
+ """List GitHub pull requests and normalize ``gh`` JSON output.
416
+
417
+ ``gh pr list --state closed`` is not documented to include merged
418
+ PRs, so any request beyond ``"open"`` fetches ``--state all`` and
419
+ filters locally by normalized (open/closed) state.
420
+ """
421
+ operation = "gh pr list"
422
+ gh_state = "open" if state == "open" else "all"
423
+ needs_local_filter = state == "closed"
424
+ fetch_limit = (
425
+ _UNBOUNDED_ISSUE_LIMIT
426
+ if needs_local_filter
427
+ else (limit if limit > 0 else _UNBOUNDED_ISSUE_LIMIT)
428
+ )
429
+ payload = self._run_gh_json(
430
+ [
431
+ "gh",
432
+ "pr",
433
+ "list",
434
+ "--state",
435
+ gh_state,
436
+ "--limit",
437
+ str(fetch_limit),
438
+ "--json",
439
+ _PR_JSON_FIELDS,
440
+ ],
441
+ cwd,
442
+ operation,
443
+ )
444
+ if not isinstance(payload, list):
445
+ raise GitHubIssueError(operation, "gh returned a non-list JSON value")
446
+ try:
447
+ pull_requests = [_pull_request_from_json(pr) for pr in payload]
448
+ except ValueError as exc:
449
+ raise GitHubIssueError(
450
+ operation,
451
+ f"gh returned invalid pull request JSON: {exc}",
452
+ ) from exc
453
+ if needs_local_filter:
454
+ pull_requests = [pr for pr in pull_requests if pr.state == "closed"]
455
+ return pull_requests if limit <= 0 else pull_requests[:limit]
456
+ return pull_requests
457
+
344
458
  @hookimpl
345
459
  def vcs_abandon_change(
346
460
  self, cl: str, revision: str, cwd: str
@@ -22,7 +22,9 @@ try:
22
22
  from sase.ace.patch.project_spec_path import preferred_project_spec_path
23
23
  except ImportError: # Older supported SASE releases expose only ChangeSpec names.
24
24
  from sase.ace.changespec import ( # type: ignore[no-redef]
25
+ # Legacy import names are adapted to canonical Patch locals.
25
26
  ChangeSpec as Patch,
27
+ # Legacy import names are adapted to canonical Patch locals.
26
28
  find_all_changespecs as find_all_patches,
27
29
  )
28
30
  from sase.ace.changespec.project_spec_path import ( # type: ignore[no-redef]
@@ -430,7 +432,9 @@ class GitHubWorkspacePlugin:
430
432
  @hookimpl
431
433
  def ws_prepare_mail(
432
434
  self,
435
+ # Legacy hook argument name retained for compatibility.
433
436
  changespec_name: str,
437
+ # Legacy hook argument name retained for compatibility.
434
438
  changespec_parent: str | None,
435
439
  project_basename: str,
436
440
  project_file: str,
@@ -461,6 +465,7 @@ class GitHubWorkspacePlugin:
461
465
  @hookimpl
462
466
  def ws_submit(
463
467
  self,
468
+ # Legacy hook argument name retained for compatibility.
464
469
  changespec_file: str,
465
470
  changespec_name: str,
466
471
  project_basename: str,
@@ -469,7 +474,7 @@ class GitHubWorkspacePlugin:
469
474
  """Submit a GitHub Patch by merging its PR."""
470
475
  from sase.workspace_provider import detect_workflow_type
471
476
 
472
- patch_file = changespec_file
477
+ patch_file = changespec_file # legacy hook argument name
473
478
  patch_name = changespec_name
474
479
  vcs_type = detect_workflow_type(patch_file)
475
480
  if vcs_type != "gh":
@@ -20,7 +20,8 @@ from sase_github.plugin import GitHubPlugin
20
20
 
21
21
  _MOCK_TARGET = "sase.vcs_provider._command_runner.subprocess.run"
22
22
  _JSON_FIELDS = (
23
- "number,title,state,body,labels,assignees,author,createdAt,updatedAt,url,comments"
23
+ "id,number,title,state,body,labels,assignees,author,createdAt,updatedAt,url,"
24
+ "comments"
24
25
  )
25
26
 
26
27
 
@@ -42,8 +43,10 @@ def _json_issue(
42
43
  assignees: tuple[str, ...] = ("octocat",),
43
44
  author: str | None = "hubot",
44
45
  comments: int = 2,
46
+ node_id: str | None = None,
45
47
  ) -> dict[str, Any]:
46
48
  return {
49
+ "id": node_id if node_id is not None else f"I_kwDOissue{number}",
47
50
  "number": number,
48
51
  "title": title,
49
52
  "state": state,
@@ -105,6 +108,7 @@ def test_list_issues_uses_json_and_normalizes_records(
105
108
  updated_at="2026-07-15T11:00:00Z",
106
109
  url="https://github.example/owner/repo/issues/42",
107
110
  comment_count=2,
111
+ provider_id="I_kwDOissue42",
108
112
  ),
109
113
  IssueWire(
110
114
  number=7,
@@ -113,6 +117,7 @@ def test_list_issues_uses_json_and_normalizes_records(
113
117
  created_at="2026-07-14T10:00:00Z",
114
118
  updated_at="2026-07-15T11:00:00Z",
115
119
  url="https://github.example/owner/repo/issues/7",
120
+ provider_id="I_kwDOissue7",
116
121
  ),
117
122
  ]
118
123
  assert _command(mock_run) == [
@@ -0,0 +1,200 @@
1
+ """Tests for the GitHub pull-request listing hook."""
2
+
3
+ import json
4
+ from typing import Any
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pluggy
8
+ import pytest
9
+ from sase.vcs_provider import PullRequestWire
10
+ from sase.vcs_provider._hookspec import VCSHookSpec
11
+ from sase.vcs_provider._plugin_manager import VCSPluginManager
12
+
13
+ from sase_github.errors import GitHubIssueError
14
+ from sase_github.plugin import GitHubPlugin
15
+
16
+ _MOCK_TARGET = "sase.vcs_provider._command_runner.subprocess.run"
17
+ _JSON_FIELDS = (
18
+ "id,number,title,state,body,isDraft,author,headRefName,baseRefName,createdAt,"
19
+ "updatedAt,closedAt,mergedAt,url"
20
+ )
21
+
22
+
23
+ @pytest.fixture
24
+ def github_provider() -> VCSPluginManager:
25
+ pm = pluggy.PluginManager("sase_vcs")
26
+ pm.add_hookspecs(VCSHookSpec)
27
+ pm.register(GitHubPlugin())
28
+ return VCSPluginManager(pm)
29
+
30
+
31
+ def _json_pr(
32
+ number: int = 42,
33
+ *,
34
+ state: str = "OPEN",
35
+ title: str = "Add feature",
36
+ body: str | None = "Description",
37
+ is_draft: bool = False,
38
+ author: str | None = "hubot",
39
+ head_ref: str = "feature-branch",
40
+ base_ref: str = "main",
41
+ closed_at: str | None = None,
42
+ merged_at: str | None = None,
43
+ node_id: str | None = None,
44
+ ) -> dict[str, Any]:
45
+ return {
46
+ "id": node_id if node_id is not None else f"PR_kwDOpr{number}",
47
+ "number": number,
48
+ "title": title,
49
+ "state": state,
50
+ "body": body,
51
+ "isDraft": is_draft,
52
+ "author": {"login": author} if author is not None else None,
53
+ "headRefName": head_ref,
54
+ "baseRefName": base_ref,
55
+ "createdAt": "2026-07-14T10:00:00Z",
56
+ "updatedAt": "2026-07-15T11:00:00Z",
57
+ "closedAt": closed_at,
58
+ "mergedAt": merged_at,
59
+ "url": f"https://github.example/owner/repo/pull/{number}",
60
+ }
61
+
62
+
63
+ def _completed(*, returncode: int = 0, stdout: str = "", stderr: str = "") -> MagicMock:
64
+ return MagicMock(returncode=returncode, stdout=stdout, stderr=stderr)
65
+
66
+
67
+ def _command(mock_run: MagicMock, index: int = 0) -> list[str]:
68
+ return mock_run.call_args_list[index].args[0]
69
+
70
+
71
+ def test_github_provider_advertises_pull_request_capability(
72
+ github_provider: VCSPluginManager,
73
+ ) -> None:
74
+ assert github_provider.supports_pull_requests() is True
75
+
76
+
77
+ @patch(_MOCK_TARGET)
78
+ def test_list_pull_requests_open_requests_state_open_and_normalizes_records(
79
+ mock_run: MagicMock, github_provider: VCSPluginManager
80
+ ) -> None:
81
+ mock_run.return_value = _completed(stdout=json.dumps([_json_pr()]))
82
+
83
+ pull_requests = github_provider.list_pull_requests(
84
+ "/workspace", state="open", limit=25
85
+ )
86
+
87
+ assert pull_requests == [
88
+ PullRequestWire(
89
+ number=42,
90
+ title="Add feature",
91
+ state="open",
92
+ provider_id="PR_kwDOpr42",
93
+ url="https://github.example/owner/repo/pull/42",
94
+ body="Description",
95
+ is_draft=False,
96
+ author="hubot",
97
+ head_ref="feature-branch",
98
+ base_ref="main",
99
+ created_at="2026-07-14T10:00:00Z",
100
+ updated_at="2026-07-15T11:00:00Z",
101
+ closed_at="",
102
+ merged_at="",
103
+ )
104
+ ]
105
+ assert _command(mock_run) == [
106
+ "gh",
107
+ "pr",
108
+ "list",
109
+ "--state",
110
+ "open",
111
+ "--limit",
112
+ "25",
113
+ "--json",
114
+ _JSON_FIELDS,
115
+ ]
116
+ assert mock_run.call_args.kwargs["cwd"] == "/workspace"
117
+
118
+
119
+ @patch(_MOCK_TARGET)
120
+ def test_list_pull_requests_all_requests_state_all_directly(
121
+ mock_run: MagicMock, github_provider: VCSPluginManager
122
+ ) -> None:
123
+ mock_run.return_value = _completed(stdout="[]")
124
+
125
+ assert github_provider.list_pull_requests("/workspace", state="all", limit=10) == []
126
+
127
+ command = _command(mock_run)
128
+ assert command[command.index("--state") + 1] == "all"
129
+ assert command[command.index("--limit") + 1] == "10"
130
+
131
+
132
+ @patch(_MOCK_TARGET)
133
+ def test_list_pull_requests_closed_folds_in_merged_and_excludes_open(
134
+ mock_run: MagicMock, github_provider: VCSPluginManager
135
+ ) -> None:
136
+ open_pr = _json_pr(1, state="OPEN")
137
+ closed_pr = _json_pr(2, state="CLOSED", closed_at="2026-07-20T00:00:00Z")
138
+ merged_pr = _json_pr(
139
+ 3,
140
+ state="MERGED",
141
+ closed_at="2026-07-21T00:00:00Z",
142
+ merged_at="2026-07-21T00:00:00Z",
143
+ )
144
+ mock_run.return_value = _completed(
145
+ stdout=json.dumps([open_pr, closed_pr, merged_pr])
146
+ )
147
+
148
+ pull_requests = github_provider.list_pull_requests("/workspace", state="closed")
149
+
150
+ assert [pr.number for pr in pull_requests] == [2, 3]
151
+ assert all(pr.state == "closed" for pr in pull_requests)
152
+ merged = next(pr for pr in pull_requests if pr.number == 3)
153
+ assert merged.merged_at == "2026-07-21T00:00:00Z"
154
+ closed_unmerged = next(pr for pr in pull_requests if pr.number == 2)
155
+ assert closed_unmerged.merged_at == ""
156
+
157
+ command = _command(mock_run)
158
+ assert command[command.index("--state") + 1] == "all"
159
+ assert command[command.index("--limit") + 1] == "1000000"
160
+
161
+
162
+ @patch(_MOCK_TARGET)
163
+ def test_list_pull_requests_non_positive_limit_requests_all_available_results(
164
+ mock_run: MagicMock, github_provider: VCSPluginManager
165
+ ) -> None:
166
+ mock_run.return_value = _completed(stdout="[]")
167
+
168
+ assert github_provider.list_pull_requests("/workspace", limit=0) == []
169
+
170
+ command = _command(mock_run)
171
+ assert command[command.index("--limit") + 1] == "1000000"
172
+
173
+
174
+ @patch(_MOCK_TARGET)
175
+ def test_list_pull_requests_normalizes_draft_flag(
176
+ mock_run: MagicMock, github_provider: VCSPluginManager
177
+ ) -> None:
178
+ mock_run.return_value = _completed(stdout=json.dumps([_json_pr(is_draft=True)]))
179
+
180
+ pull_requests = github_provider.list_pull_requests("/workspace")
181
+
182
+ assert pull_requests[0].is_draft is True
183
+
184
+
185
+ @pytest.mark.parametrize(
186
+ "stdout",
187
+ [
188
+ "not json",
189
+ "{}",
190
+ json.dumps([{"number": 1, "title": "Missing state"}]),
191
+ ],
192
+ )
193
+ @patch(_MOCK_TARGET)
194
+ def test_malformed_pull_request_json_raises_typed_error(
195
+ mock_run: MagicMock, stdout: str, github_provider: VCSPluginManager
196
+ ) -> None:
197
+ mock_run.return_value = _completed(stdout=stdout)
198
+
199
+ with pytest.raises(GitHubIssueError, match="gh pr list"):
200
+ github_provider.list_pull_requests("/workspace")
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.2.2"
3
- }
File without changes
File without changes
File without changes
File without changes
File without changes