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.
- sase_github-0.2.4/.release-please-manifest.json +3 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/CHANGELOG.md +14 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/PKG-INFO +2 -2
- {sase_github-0.2.2 → sase_github-0.2.4}/pyproject.toml +1 -1
- {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/plugin.py +125 -11
- {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/workspace_plugin.py +6 -1
- {sase_github-0.2.2 → sase_github-0.2.4}/tests/test_github_issues.py +6 -1
- sase_github-0.2.4/tests/test_github_pull_requests.py +200 -0
- sase_github-0.2.2/.release-please-manifest.json +0 -3
- {sase_github-0.2.2 → sase_github-0.2.4}/.github/workflows/ci.yml +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/.github/workflows/pr-title.yml +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/.github/workflows/publish.yml +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/.sase_beads/beads.db +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/.sase_beads/config.json +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/.sase_beads/issues.jsonl +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/CLAUDE.md +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/Justfile +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/LICENSE +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/README.md +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/docs/architecture.md +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/docs/configuration.md +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/docs/xprompts.md +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/release-please-config.json +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/__init__.py +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/config.py +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/default_config.yml +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/errors.py +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/scripts/__init__.py +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/scripts/gh_setup.py +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/scripts/new_pr_desc_get_context.py +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/xprompts/gh.yml +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/xprompts/new_pr_desc.yml +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/xprompts/pr_diff.yml +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/src/sase_github/xprompts/prdd.yml +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/tests/__init__.py +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/tests/test_ci_install_contract.py +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/tests/test_config.py +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/tests/test_github_plugin.py +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/tests/test_submit_with_recorded_pr.py +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/tests/test_workspace_plugin.py +0 -0
- {sase_github-0.2.2 → sase_github-0.2.4}/uv.lock +0 -0
|
@@ -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
|
|
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
|
192
|
-
raw = self.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|