|
8 | 8 | from typing import cast |
9 | 9 | from urllib.parse import urlparse |
10 | 10 |
|
11 | | -from gh_llm.models import PageInfo, PullRequestMeta, PullRequestRef, TimelineEvent, TimelinePage |
| 11 | +from gh_llm.models import CheckItem, PageInfo, PullRequestMeta, PullRequestRef, TimelineEvent, TimelinePage |
12 | 12 |
|
13 | 13 | MAX_INLINE_TEXT = 8000 |
14 | 14 | MAX_INLINE_LINES = 200 |
@@ -244,6 +244,41 @@ class ReferenceSubject: |
244 | 244 | } |
245 | 245 | """.strip() |
246 | 246 |
|
| 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 | + |
247 | 282 |
|
248 | 283 | class GitHubClient: |
249 | 284 | def __init__(self) -> None: |
@@ -508,6 +543,65 @@ def _get_viewer_login(self) -> str: |
508 | 543 | login = _as_optional_str(payload.get("login")) |
509 | 544 | return login or "" |
510 | 545 |
|
| 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 | + |
511 | 605 | def _run_graphql_connection(query: str, variables: dict[str, str | int]) -> dict[str, object]: |
512 | 606 | payload = _run_graphql_payload(query, variables) |
513 | 607 | data_obj = _as_dict(payload.get("data"), context="graphql data") |
@@ -931,7 +1025,7 @@ def _render_review_thread_block( |
931 | 1025 | viewer_login=viewer_login, |
932 | 1026 | ) |
933 | 1027 | ) |
934 | | - lines.append(f" 🆔 thread_id: {thread_id}") |
| 1028 | + lines.append(f" ◌ thread_id: {thread_id}") |
935 | 1029 | lines.append(" ⌨ reply_body: '<reply>'") |
936 | 1030 | lines.append( |
937 | 1031 | 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( |
981 | 1075 | lines.append(f" Reactions: {reactions_summary}") |
982 | 1076 | comment_id = _as_optional_str(comment.get("id")) or "" |
983 | 1077 | if comment_id and author == viewer_login: |
984 | | - lines.append(f" 🆔 comment_id: {comment_id}") |
| 1078 | + lines.append(f" ◌ comment_id: {comment_id}") |
985 | 1079 | lines.append(" ⌨ comment_body: '<comment_body>'") |
986 | 1080 | lines.append( |
987 | 1081 | 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: |
1154 | 1248 | return any(pattern in lowered for pattern in retryable_patterns) |
1155 | 1249 |
|
1156 | 1250 |
|
| 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 | + |
1157 | 1281 | def _reference_subject_summary(source: dict[str, object] | None) -> ReferenceSubject | None: |
1158 | 1282 | if source is None: |
1159 | 1283 | return None |
|
0 commit comments