Skip to content

Commit 7b8c59b

Browse files
committed
✨ feat: Add CI checks functionality for pull requests with rendering support
1 parent 7f17f26 commit 7b8c59b

5 files changed

Lines changed: 291 additions & 5 deletions

File tree

src/gh_llm/commands/pr.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from gh_llm.github_api import GitHubClient
66
from gh_llm.pager import DEFAULT_PAGE_SIZE, TimelinePager
77
from gh_llm.render import (
8+
render_checks_section,
89
render_event_detail,
910
render_expand_hints,
1011
render_header,
@@ -60,6 +61,12 @@ def register_pr_parser(subparsers: Any) -> None:
6061
review_expand_parser.add_argument("--page-size", type=int, help="timeline entries per page")
6162
review_expand_parser.set_defaults(handler=cmd_pr_review_expand)
6263

64+
checks_parser = pr_subparsers.add_parser("checks", help="show CI checks for the pull request")
65+
checks_parser.add_argument("--pr", help="PR number/url/branch")
66+
checks_parser.add_argument("--repo", help="repository in OWNER/REPO format")
67+
checks_parser.add_argument("--all", action="store_true", help="show all checks including passed")
68+
checks_parser.set_defaults(handler=cmd_pr_checks)
69+
6370
thread_reply_parser = pr_subparsers.add_parser(
6471
"thread-reply", help="reply to a pull request review thread"
6572
)
@@ -141,6 +148,14 @@ def cmd_pr_view(args: Any) -> int:
141148
print()
142149
for line in render_expand_hints(context, shown_pages):
143150
print(line)
151+
checks = client.fetch_checks(meta.ref) if meta.state == "OPEN" else []
152+
for line in render_checks_section(
153+
context=context,
154+
checks=checks,
155+
show_all=False,
156+
is_open=(meta.state == "OPEN"),
157+
):
158+
print(line)
144159
for line in render_pr_actions(context):
145160
print(line)
146161

@@ -223,6 +238,21 @@ def cmd_pr_review_expand(args: Any) -> int:
223238
return 0
224239

225240

241+
def cmd_pr_checks(args: Any) -> int:
242+
client = GitHubClient()
243+
pager = TimelinePager(client)
244+
context, meta = _resolve_context_and_meta(client=client, pager=pager, args=args)
245+
checks = client.fetch_checks(meta.ref)
246+
for line in render_checks_section(
247+
context=context,
248+
checks=checks,
249+
show_all=bool(args.all),
250+
is_open=(meta.state == "OPEN"),
251+
):
252+
print(line)
253+
return 0
254+
255+
226256
def cmd_pr_thread_reply(args: Any) -> int:
227257
client = GitHubClient()
228258
if args.repo is not None and args.pr is None:

src/gh_llm/github_api.py

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import cast
99
from urllib.parse import urlparse
1010

11-
from gh_llm.models import PageInfo, PullRequestMeta, PullRequestRef, TimelineEvent, TimelinePage
11+
from gh_llm.models import CheckItem, PageInfo, PullRequestMeta, PullRequestRef, TimelineEvent, TimelinePage
1212

1313
MAX_INLINE_TEXT = 8000
1414
MAX_INLINE_LINES = 200
@@ -244,6 +244,41 @@ class ReferenceSubject:
244244
}
245245
""".strip()
246246

247+
CHECKS_QUERY = """
248+
query($owner:String!,$name:String!,$number:Int!){
249+
repository(owner:$owner,name:$name){
250+
pullRequest(number:$number){
251+
commits(last:1){
252+
nodes{
253+
commit{
254+
statusCheckRollup{
255+
contexts(first:100){
256+
nodes{
257+
__typename
258+
... on CheckRun{
259+
name
260+
status
261+
conclusion
262+
detailsUrl
263+
databaseId
264+
}
265+
... on StatusContext{
266+
context
267+
state
268+
targetUrl
269+
description
270+
}
271+
}
272+
}
273+
}
274+
}
275+
}
276+
}
277+
}
278+
}
279+
}
280+
""".strip()
281+
247282

248283
class GitHubClient:
249284
def __init__(self) -> None:
@@ -508,6 +543,65 @@ def _get_viewer_login(self) -> str:
508543
login = _as_optional_str(payload.get("login"))
509544
return login or ""
510545

546+
def fetch_checks(self, ref: PullRequestRef) -> list[CheckItem]:
547+
payload = _run_graphql_payload(
548+
CHECKS_QUERY,
549+
{"owner": ref.owner, "name": ref.name, "number": ref.number},
550+
)
551+
data_obj = _as_dict(payload.get("data"), context="graphql data")
552+
repo_obj = _as_dict(data_obj.get("repository"), context="repository")
553+
pr_obj = _as_dict(repo_obj.get("pullRequest"), context="pullRequest")
554+
commits_obj = _as_dict(pr_obj.get("commits"), context="commits")
555+
nodes = _as_list(commits_obj.get("nodes"))
556+
if not nodes:
557+
return []
558+
head = _as_dict(nodes[0], context="commit node")
559+
commit_obj = _as_dict(head.get("commit"), context="commit")
560+
rollup_obj = _as_dict_optional(commit_obj.get("statusCheckRollup"))
561+
if rollup_obj is None:
562+
return []
563+
contexts_obj = _as_dict_optional(rollup_obj.get("contexts"))
564+
if contexts_obj is None:
565+
return []
566+
567+
items: list[CheckItem] = []
568+
for raw in _as_list(contexts_obj.get("nodes")):
569+
node = _as_dict(raw, context="check context")
570+
typename = _as_optional_str(node.get("__typename")) or ""
571+
if typename == "CheckRun":
572+
name = (_as_optional_str(node.get("name")) or "").strip() or "(unnamed check run)"
573+
status = _as_optional_str(node.get("status")) or "UNKNOWN"
574+
conclusion = _as_optional_str(node.get("conclusion"))
575+
label = f"{status}/{(conclusion or 'NONE')}"
576+
details_url = _as_optional_str(node.get("detailsUrl"))
577+
run_id, job_id = _extract_actions_run_and_job_ids(details_url)
578+
items.append(
579+
CheckItem(
580+
name=name,
581+
kind="check-run",
582+
status=label,
583+
passed=_is_check_run_passed(status=status, conclusion=conclusion),
584+
details_url=details_url,
585+
run_id=run_id,
586+
job_id=job_id,
587+
)
588+
)
589+
continue
590+
if typename == "StatusContext":
591+
name = (_as_optional_str(node.get("context")) or "").strip() or "(unnamed status)"
592+
state = _as_optional_str(node.get("state")) or "UNKNOWN"
593+
items.append(
594+
CheckItem(
595+
name=name,
596+
kind="status-context",
597+
status=state,
598+
passed=(state == "SUCCESS"),
599+
details_url=_as_optional_str(node.get("targetUrl")),
600+
run_id=None,
601+
)
602+
)
603+
return items
604+
511605
def _run_graphql_connection(query: str, variables: dict[str, str | int]) -> dict[str, object]:
512606
payload = _run_graphql_payload(query, variables)
513607
data_obj = _as_dict(payload.get("data"), context="graphql data")
@@ -931,7 +1025,7 @@ def _render_review_thread_block(
9311025
viewer_login=viewer_login,
9321026
)
9331027
)
934-
lines.append(f" 🆔 thread_id: {thread_id}")
1028+
lines.append(f" thread_id: {thread_id}")
9351029
lines.append(" ⌨ reply_body: '<reply>'")
9361030
lines.append(
9371031
f" ⏎ Reply via gh-llm: `gh-llm pr thread-reply {thread_id} --body '<reply>' --pr {ref.number} --repo {ref.owner}/{ref.name}`"
@@ -981,7 +1075,7 @@ def _render_review_comment_block(
9811075
lines.append(f" Reactions: {reactions_summary}")
9821076
comment_id = _as_optional_str(comment.get("id")) or ""
9831077
if comment_id and author == viewer_login:
984-
lines.append(f" 🆔 comment_id: {comment_id}")
1078+
lines.append(f" comment_id: {comment_id}")
9851079
lines.append(" ⌨ comment_body: '<comment_body>'")
9861080
lines.append(
9871081
f" ⏎ Edit comment via gh-llm: `gh-llm pr comment-edit {comment_id} --body '<comment_body>' --pr {ref.number} --repo {ref.owner}/{ref.name}`"
@@ -1154,6 +1248,36 @@ def _is_retryable_gh_error(stderr: str) -> bool:
11541248
return any(pattern in lowered for pattern in retryable_patterns)
11551249

11561250

1251+
def _is_check_run_passed(*, status: str, conclusion: str | None) -> bool:
1252+
if status != "COMPLETED":
1253+
return False
1254+
return (conclusion or "").upper() in {"SUCCESS", "NEUTRAL", "SKIPPED"}
1255+
1256+
1257+
def _extract_actions_run_and_job_ids(details_url: str | None) -> tuple[int | None, int | None]:
1258+
if not details_url:
1259+
return None, None
1260+
parsed = urlparse(details_url)
1261+
parts = [segment for segment in parsed.path.split("/") if segment]
1262+
# Expected shape: /<owner>/<repo>/actions/runs/<run_id>/job/<job_id>
1263+
run_id: int | None = None
1264+
job_id: int | None = None
1265+
for idx, part in enumerate(parts):
1266+
if part == "runs" and idx + 1 < len(parts):
1267+
run_id = _parse_positive_int(parts[idx + 1])
1268+
if part == "job" and idx + 1 < len(parts):
1269+
job_id = _parse_positive_int(parts[idx + 1])
1270+
return run_id, job_id
1271+
1272+
1273+
def _parse_positive_int(raw: str) -> int | None:
1274+
try:
1275+
value = int(raw)
1276+
except ValueError:
1277+
return None
1278+
return value if value > 0 else None
1279+
1280+
11571281
def _reference_subject_summary(source: dict[str, object] | None) -> ReferenceSubject | None:
11581282
if source is None:
11591283
return None

src/gh_llm/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,17 @@ class TimelinePage:
5757
page_info: PageInfo
5858

5959

60+
@dataclass(frozen=True)
61+
class CheckItem:
62+
name: str
63+
kind: str
64+
status: str
65+
passed: bool
66+
details_url: str | None = None
67+
run_id: int | None = None
68+
job_id: int | None = None
69+
70+
6071
@dataclass
6172
class TimelineContext:
6273
owner: str

src/gh_llm/render.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
if TYPE_CHECKING:
88
from string.templatelib import Template
99

10-
from gh_llm.models import TimelineContext, TimelineEvent, TimelinePage
10+
from gh_llm.models import CheckItem, TimelineContext, TimelineEvent, TimelinePage
1111

1212

1313
def render_header(context: TimelineContext) -> list[str]:
@@ -89,6 +89,65 @@ def render_pr_actions(context: TimelineContext) -> list[str]:
8989
]
9090

9191

92+
def render_checks_section(
93+
*,
94+
context: TimelineContext,
95+
checks: list[CheckItem],
96+
show_all: bool,
97+
is_open: bool,
98+
) -> list[str]:
99+
repo = f"{context.owner}/{context.name}"
100+
if not is_open:
101+
return [
102+
"## Checks",
103+
f"Closed PR: checks are hidden by default. ⏎ run `gh-llm pr checks --pr {context.number} --repo {repo} --all`",
104+
"",
105+
]
106+
107+
visible = checks if show_all else [item for item in checks if not item.passed]
108+
hidden_count = max(0, len(checks) - len(visible))
109+
lines = ["## Checks"]
110+
if not visible:
111+
if checks:
112+
lines.append("All checks passed.")
113+
else:
114+
lines.append("(no checks found)")
115+
else:
116+
for idx, item in enumerate(visible, start=1):
117+
lines.append(f"{idx}. [{item.status}] {item.name} ({item.kind})")
118+
if item.run_id is not None:
119+
if item.job_id is not None:
120+
lines.append(
121+
f" ⏎ details: `gh run view {item.run_id} --job {item.job_id} --repo {repo}`"
122+
)
123+
lines.append(
124+
f" ⏎ logs: `gh run view {item.run_id} --job {item.job_id} --log --repo {repo}`"
125+
)
126+
else:
127+
lines.append(
128+
f" ⏎ details: `gh run view {item.run_id} --repo {repo}`"
129+
)
130+
lines.append(
131+
f" ⏎ logs: `gh run view {item.run_id} --log --repo {repo}`"
132+
)
133+
elif item.details_url:
134+
lines.append(f" ⏎ details: `{item.details_url}`")
135+
if show_all:
136+
lines.append(
137+
f"⏎ show only non-passed: `gh-llm pr checks --pr {context.number} --repo {repo}`"
138+
)
139+
elif hidden_count > 0:
140+
lines.append(
141+
f"{hidden_count} passed checks hidden. ⏎ show all: `gh-llm pr checks --pr {context.number} --repo {repo} --all`"
142+
)
143+
else:
144+
lines.append(
145+
f"⏎ show all: `gh-llm pr checks --pr {context.number} --repo {repo} --all`"
146+
)
147+
lines.append("")
148+
return lines
149+
150+
92151
def render_hidden_gap(context: TimelineContext, hidden_pages: list[int]) -> list[str]:
93152
if not hidden_pages:
94153
return []
@@ -118,7 +177,7 @@ def _render_item(index: int, event: TimelineEvent, context: TimelineContext) ->
118177
if event.reactions_summary:
119178
lines.append(f" Reactions: {event.reactions_summary}")
120179
if event.editable_comment_id:
121-
lines.append(f" 🆔 comment_id: {event.editable_comment_id}")
180+
lines.append(f" comment_id: {event.editable_comment_id}")
122181
lines.append(" ⌨ comment_body: '<comment_body>'")
123182
lines.append(
124183
f" ⏎ Edit comment via gh-llm: `gh-llm pr comment-edit {event.editable_comment_id} --body '<comment_body>' --pr {context.number} --repo {context.owner}/{context.name}`"

0 commit comments

Comments
 (0)