mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
.stamphog/ was a config directory at the repo root — policy.yml, review-guidance.md — read by the PR review agent. It is .stamp/ now, and the 34 files that name it follow: the workflows, tools/pr-approval-agent, tach.toml, the sandbox Dockerfile, bin/debrand. One behaviour change worth naming rather than burying: the agent finds its own previous PR comment by an HTML marker, `<!-- stamphog:v1 ... -->`, and that marker is now `<!-- stamp:v1 ... -->`. Open PRs will get one new comment instead of an edit, once, and then match again. insights/test/setup_receivers_baseline.txt records registered signal receivers by module path, so it names products.review.backend now. stamphog: zero occurrences anywhere in the tree. review_hog: only inside migration files, whose names are their identity in django_migrations. Co-authored-by: Hanzo Dev <[email protected]>
622 lines
23 KiB
Python
622 lines
23 KiB
Python
"""GitHub data fetching via gh CLI + local git.
|
|
|
|
File stats come from the local checkout (git diff --numstat), everything
|
|
else (PR metadata, reviews, comments, check runs) from the GitHub API.
|
|
Also handles team membership checks for the ownership gate.
|
|
"""
|
|
|
|
import re
|
|
import json
|
|
import subprocess
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
|
|
@dataclass
|
|
class PRData:
|
|
"""All GitHub data needed to evaluate a PR."""
|
|
|
|
number: int
|
|
repo: str
|
|
title: str
|
|
state: str
|
|
draft: bool
|
|
mergeable_state: str
|
|
author: str
|
|
labels: list[str]
|
|
base_sha: str
|
|
head_sha: str
|
|
files: list[dict]
|
|
reviews: list[dict]
|
|
review_comments: list[dict]
|
|
check_runs: list[dict]
|
|
author_is_bot: bool = False
|
|
pr_reactions: list[dict] = field(default_factory=list)
|
|
body: str = ""
|
|
discussion: list[dict] = field(default_factory=list)
|
|
|
|
@property
|
|
def file_paths(self) -> list[str]:
|
|
return [f["filename"] for f in self.files]
|
|
|
|
@property
|
|
def lines_added(self) -> int:
|
|
return sum(f["additions"] for f in self.files)
|
|
|
|
@property
|
|
def lines_deleted(self) -> int:
|
|
return sum(f["deletions"] for f in self.files)
|
|
|
|
@property
|
|
def lines_total(self) -> int:
|
|
return self.lines_added + self.lines_deleted
|
|
|
|
@property
|
|
def has_new_files(self) -> bool:
|
|
return any(f.get("status") == "A" for f in self.files)
|
|
|
|
|
|
_TRUSTED_ASSOCIATIONS = {"MEMBER", "OWNER", "COLLABORATOR"}
|
|
|
|
# Machine users that are real org members (type "User") but should still be
|
|
# treated as bots. GitHub Apps already report type "Bot" and aren't listed here.
|
|
_BOT_MACHINE_USERS = {"insights-bot"}
|
|
|
|
# Bots whose reactions are deliberate review verdicts (👍 = reviewed clean,
|
|
# 👀 = review in flight). Other installed apps react for unrelated reasons
|
|
# (e.g. inkeep's 👎 is docs feedback), so bot reactions are allowlisted rather
|
|
# than trusted wholesale. GraphQL returns bot logins with the "[bot]" suffix.
|
|
TRUSTED_REACTOR_BOTS = {
|
|
"chatgpt-codex-connector[bot]",
|
|
"copilot-pull-request-reviewer[bot]",
|
|
"greptile-apps[bot]",
|
|
"hex-security-app[bot]",
|
|
"veria-ai[bot]",
|
|
}
|
|
|
|
|
|
def is_bot_author(user: dict) -> bool:
|
|
"""True when the PR author is a bot or machine account.
|
|
|
|
GitHub Apps (dependabot, mendral, other agents) report user.type == "Bot";
|
|
machine users like insights-bot are type "User", so match them by login.
|
|
Mirrors the bot definition gating the jobs in pr-approval-agent.yml.
|
|
"""
|
|
if user.get("type") == "Bot":
|
|
return True
|
|
login = (user.get("login") or "").lower()
|
|
return "[bot]" in login or login in _BOT_MACHINE_USERS
|
|
|
|
|
|
# Stamp's own review identities: REFUSE/ESCALATE comment reviews post via
|
|
# the GitHub App (stamp[bot]); APPROVE reviews post via the workflow's
|
|
# GITHUB_TOKEN (github-actions[bot]) so they count toward branch protection.
|
|
_SELF_REVIEW_LOGINS = {"stamp[bot]", "github-actions[bot]"}
|
|
|
|
|
|
def _prompt_worthy_author(login: str | None, association: str | None, is_bot: bool) -> bool:
|
|
"""One author-trust gate for reviews, inline comments, and discussion.
|
|
|
|
Drops stamp's own prior verdicts (they describe a stale snapshot the next
|
|
run would misread as third-party tampering) and admits only trusted org
|
|
members or bots. Without this, an untrusted external commenter could reach
|
|
the prompt via discussion and grief it with a fake maintainer hold or forge
|
|
a fake assurance claim. REST and GraphQL spell the author differently, so
|
|
the caller passes the already-extracted login/association/bot-ness.
|
|
"""
|
|
if login in _SELF_REVIEW_LOGINS:
|
|
return False
|
|
return is_bot or association == "BOT" or association in _TRUSTED_ASSOCIATIONS
|
|
|
|
|
|
def _normalize_reviews_for_prompt(reviews_raw: list[dict], head_sha: str) -> list[dict]:
|
|
"""Normalize top-level reviews for the reviewer prompt.
|
|
|
|
Preserve trusted/bot reviews, and annotate whether each review was left on
|
|
the current PR head. This lets the LLM distinguish active feedback from
|
|
older context that may already have been addressed in follow-up commits.
|
|
|
|
Stamp's own prior reviews are excluded: they describe an earlier
|
|
snapshot of the PR, are never independent review signal, and the reviewer
|
|
has no way to recognize them as its own — re-reading a stale verdict after
|
|
the PR state changed makes it suspect tampering and refuse forever.
|
|
"""
|
|
normalized_reviews = []
|
|
for review in reviews_raw:
|
|
user = review.get("user") or {}
|
|
if not _prompt_worthy_author(user.get("login"), review.get("author_association"), user.get("type") == "Bot"):
|
|
continue
|
|
|
|
commit_id = review.get("commit_id")
|
|
normalized_reviews.append(
|
|
{
|
|
"user": review["user"]["login"],
|
|
"state": review["state"],
|
|
"body": review.get("body", ""),
|
|
"commit_id": commit_id,
|
|
"is_current_head": commit_id == head_sha,
|
|
"submitted_at": review.get("submitted_at"),
|
|
}
|
|
)
|
|
|
|
return normalized_reviews
|
|
|
|
|
|
def _normalize_discussion_for_prompt(comments_raw: list[dict]) -> list[dict]:
|
|
"""Normalize the PR's issue-comment timeline for the reviewer prompt.
|
|
|
|
These are the general discussion comments (not inline review comments).
|
|
Stamp's own bot comments are excluded and the same author-trust gate as
|
|
reviews/inline comments applies, so an untrusted external commenter can't
|
|
slip a fake maintainer hold or assurance claim into the prompt.
|
|
"""
|
|
normalized = []
|
|
for comment in comments_raw:
|
|
user = comment.get("user") or {}
|
|
if not _prompt_worthy_author(user.get("login"), comment.get("author_association"), user.get("type") == "Bot"):
|
|
continue
|
|
normalized.append(
|
|
{
|
|
"user": user.get("login", "ghost"),
|
|
"body": comment.get("body", ""),
|
|
"created_at": comment.get("created_at"),
|
|
}
|
|
)
|
|
return normalized
|
|
|
|
|
|
# GitHub spells reaction contents two ways: REST returns "+1"/"-1", GraphQL
|
|
# returns "THUMBS_UP"/"THUMBS_DOWN". Normalize both to an emoji so the reviewer
|
|
# sees a consistent signal regardless of which API surfaced the reaction.
|
|
_REACTION_EMOJI = {
|
|
"+1": "👍",
|
|
"thumbs_up": "👍",
|
|
"-1": "👎",
|
|
"thumbs_down": "👎",
|
|
"laugh": "😄",
|
|
"hooray": "🎉",
|
|
"confused": "😕",
|
|
"heart": "❤️",
|
|
"rocket": "🚀",
|
|
"eyes": "👀",
|
|
}
|
|
|
|
|
|
def _reaction_emoji(content: str) -> str:
|
|
"""Map a GitHub reaction content string (REST or GraphQL) to an emoji.
|
|
|
|
Unknown values pass through unchanged so a newly-added reaction type still
|
|
surfaces rather than silently disappearing.
|
|
"""
|
|
return _REACTION_EMOJI.get(content.lower(), content)
|
|
|
|
|
|
def _is_org_member(org: str, login: str) -> bool:
|
|
"""Best-effort org-membership check; False on any error (fail closed)."""
|
|
try:
|
|
result = subprocess.run(
|
|
["gh", "api", f"orgs/{org}/members/{login}"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
return result.returncode == 0
|
|
except subprocess.TimeoutExpired:
|
|
return False
|
|
|
|
|
|
def _trusted_reactor_predicate(repo: str, author: str) -> Callable[[str], bool]:
|
|
"""Build a memoized `login -> bool` gate for whose reactions to trust.
|
|
|
|
Reactions on a public PR can come from anyone, so only trust allowlisted
|
|
reviewer bots and org members, and never the PR author (a self-reaction is
|
|
not an independent signal). Unknown or erroring logins fail closed. Without
|
|
this, any GitHub user could block auto-approval with an 👀 or fake an
|
|
independent review with a 👍.
|
|
"""
|
|
org = repo.split("/", 1)[0]
|
|
cache: dict[str, bool] = {}
|
|
|
|
def is_trusted(login: str) -> bool:
|
|
if not login or login == "ghost" or login == author:
|
|
return False
|
|
if login not in cache:
|
|
low = login.lower()
|
|
if low.endswith("[bot]"):
|
|
cache[login] = low in TRUSTED_REACTOR_BOTS
|
|
else:
|
|
cache[login] = _is_org_member(org, login)
|
|
return cache[login]
|
|
|
|
return is_trusted
|
|
|
|
|
|
def _normalize_reactions(node: dict, is_trusted: Callable[[str], bool]) -> list[dict]:
|
|
"""Normalize a GraphQL Reactable node's trusted reactions to [{user, emoji}].
|
|
|
|
Works for any object that carries a `reactions` connection — the PR itself
|
|
or an individual review comment. Reactions from untrusted actors (see
|
|
`_trusted_reactor_predicate`) are dropped so they can't influence the verdict.
|
|
"""
|
|
reactions = []
|
|
for rn in (node.get("reactions") or {}).get("nodes") or []:
|
|
login = (rn.get("user") or {}).get("login", "ghost")
|
|
if is_trusted(login):
|
|
reactions.append(
|
|
{
|
|
"user": login,
|
|
"emoji": _reaction_emoji(rn.get("content", "")),
|
|
"created_at": rn.get("createdAt"),
|
|
}
|
|
)
|
|
return reactions
|
|
|
|
|
|
def _gh_api(endpoint: str, *, paginate: bool = False) -> dict | list:
|
|
cmd = ["gh", "api", endpoint]
|
|
if paginate:
|
|
# --paginate alone emits one JSON document per page, which json.loads
|
|
# rejects on 2+ pages; --slurp wraps the pages in one outer array.
|
|
cmd.extend(["--paginate", "--slurp"])
|
|
else:
|
|
sep = "&" if "?" in endpoint else "?"
|
|
cmd[2] = f"{endpoint}{sep}per_page=100"
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"gh api {endpoint} failed: {result.stderr.strip()}")
|
|
data = json.loads(result.stdout)
|
|
if paginate:
|
|
flat: list = []
|
|
for page in data:
|
|
if isinstance(page, list):
|
|
flat.extend(page)
|
|
else:
|
|
flat.append(page)
|
|
return flat
|
|
return data
|
|
|
|
|
|
# GitHub rejects GraphQL queries whose worst-case node count — the product of
|
|
# nested `first:` sizes — exceeds 500,000, before executing anything.
|
|
_REVIEW_THREADS_QUERY = """
|
|
query($owner: String!, $name: String!, $pr: Int!, $threadCursor: String) {
|
|
repository(owner: $owner, name: $name) {
|
|
pullRequest(number: $pr) {
|
|
reactions(first: 100) {
|
|
nodes {
|
|
content
|
|
createdAt
|
|
user { login }
|
|
}
|
|
}
|
|
reviewThreads(first: 100, after: $threadCursor) {
|
|
pageInfo { hasNextPage endCursor }
|
|
nodes {
|
|
isResolved
|
|
isOutdated
|
|
path
|
|
line
|
|
comments(first: 50) {
|
|
pageInfo { hasNextPage }
|
|
nodes {
|
|
author { login __typename }
|
|
authorAssociation
|
|
body
|
|
databaseId
|
|
replyTo { databaseId }
|
|
reactions(first: 20) {
|
|
nodes {
|
|
content
|
|
createdAt
|
|
user { login }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
def _gh_graphql(query: str, variables: dict | None = None) -> dict:
|
|
"""Run a GraphQL query via gh api graphql with proper variable passing."""
|
|
cmd = ["gh", "api", "graphql", "-f", f"query={query}"]
|
|
for key, value in (variables or {}).items():
|
|
if value is None:
|
|
cmd.extend(["-F", f"{key}=null"])
|
|
elif isinstance(value, int):
|
|
cmd.extend(["-F", f"{key}={value}"])
|
|
else:
|
|
cmd.extend(["-f", f"{key}={value}"])
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"gh api graphql failed: {result.stderr.strip()}")
|
|
data = json.loads(result.stdout)
|
|
if "errors" in data:
|
|
raise RuntimeError(f"GraphQL errors: {data['errors']}")
|
|
return data
|
|
|
|
|
|
def _fetch_threads_and_reactions(repo: str, pr_number: int, author: str) -> tuple[list[dict], list[dict]]:
|
|
"""Fetch review-thread comments and reactions on the PR in one GraphQL call.
|
|
|
|
Inline comments (each with their own reactions) and the reactions left on
|
|
the PR itself come back from the same query, so no extra REST round trip is
|
|
needed. Reactions are filtered to trusted, non-author actors. Returns
|
|
(comments, pr_reactions); raises if any comment page is truncated.
|
|
"""
|
|
owner, name = repo.split("/", 1)
|
|
variables: dict = {"owner": owner, "name": name, "pr": pr_number, "threadCursor": None}
|
|
is_trusted = _trusted_reactor_predicate(repo, author)
|
|
|
|
comments: list[dict] = []
|
|
pr_reactions: list[dict] = []
|
|
while True:
|
|
data = _gh_graphql(_REVIEW_THREADS_QUERY, variables)
|
|
pull_request = data["data"]["repository"]["pullRequest"]
|
|
# Reactions on the PR repeat on the pullRequest node on every page;
|
|
# re-reading the (≤20) nodes each pass is trivial and avoids a flag.
|
|
pr_reactions = _normalize_reactions(pull_request, is_trusted)
|
|
review_threads = pull_request["reviewThreads"]
|
|
threads = review_threads["nodes"]
|
|
|
|
for thread in threads:
|
|
comment_page = thread["comments"]
|
|
if comment_page["pageInfo"]["hasNextPage"]:
|
|
raise RuntimeError(
|
|
f"Review thread on {thread.get('path')}:{thread.get('line')} "
|
|
f"has >50 comments — pagination not implemented, escalate to human review"
|
|
)
|
|
for c in comment_page["nodes"]:
|
|
# Same author-trust gate as reviews and discussion: stamp's
|
|
# own inline comments describe an earlier snapshot (feeding them
|
|
# back reads as impersonation) and untrusted external commenters
|
|
# are dropped. Reactions on these comments are also dropped — a
|
|
# 👍 on stamp's comment endorses stamp's verdict, not the PR.
|
|
comment_author = c.get("author") or {}
|
|
if not _prompt_worthy_author(
|
|
comment_author.get("login"), c.get("authorAssociation"), comment_author.get("__typename") == "Bot"
|
|
):
|
|
continue
|
|
reply_to = c.get("replyTo")
|
|
comments.append(
|
|
{
|
|
"user": comment_author.get("login", "ghost"),
|
|
"body": c.get("body", ""),
|
|
"path": thread.get("path", ""),
|
|
"line": thread.get("line"),
|
|
"in_reply_to_id": reply_to["databaseId"] if reply_to else None,
|
|
"is_resolved": thread["isResolved"],
|
|
"is_outdated": thread["isOutdated"],
|
|
"reactions": _normalize_reactions(c, is_trusted),
|
|
}
|
|
)
|
|
|
|
page_info = review_threads["pageInfo"]
|
|
if not page_info["hasNextPage"]:
|
|
break
|
|
variables["threadCursor"] = page_info["endCursor"]
|
|
|
|
return comments, pr_reactions
|
|
|
|
|
|
def _git_diff_files(base_sha: str, head_sha: str, repo_root: Path) -> list[dict]:
|
|
"""Get changed files with line counts and status from the local checkout."""
|
|
diff_range = f"{base_sha}...{head_sha}"
|
|
run_opts = {"capture_output": True, "text": True, "timeout": 30, "cwd": repo_root}
|
|
|
|
numstat = subprocess.run(["git", "diff", "--numstat", diff_range], **run_opts)
|
|
if numstat.returncode != 0:
|
|
raise RuntimeError(f"git diff --numstat failed: {numstat.stderr.strip()}")
|
|
|
|
name_status = subprocess.run(["git", "diff", "--name-status", diff_range], **run_opts)
|
|
status_map: dict[str, str] = {}
|
|
for line in name_status.stdout.strip().splitlines():
|
|
parts = line.split("\t", 1)
|
|
if len(parts) == 2:
|
|
status_map[parts[1]] = parts[0]
|
|
|
|
files = []
|
|
for line in numstat.stdout.strip().splitlines():
|
|
if not line:
|
|
continue
|
|
parts = line.split("\t", 2)
|
|
if len(parts) != 3:
|
|
continue
|
|
added, deleted, filename = parts
|
|
is_binary = added == "-"
|
|
files.append(
|
|
{
|
|
"filename": filename,
|
|
"additions": int(added) if not is_binary else 0,
|
|
"deletions": int(deleted) if not is_binary else 0,
|
|
"binary": is_binary,
|
|
"status": status_map.get(filename, "M"),
|
|
}
|
|
)
|
|
return files
|
|
|
|
|
|
def write_pr_diff(base_sha: str, head_sha: str, dest: Path, repo_root: Path) -> Path:
|
|
"""Write the base...head PR diff to `dest` from the local checkout.
|
|
|
|
Shared by the reviewer (feeds the LLM the diff to read) and the familiarity
|
|
signal (parses the same diff for base-side modified line ranges), so the
|
|
`git diff` invocation lives in one place.
|
|
"""
|
|
result = subprocess.run(
|
|
["git", "diff", f"{base_sha}...{head_sha}"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
cwd=repo_root,
|
|
)
|
|
dest.write_text(result.stdout if result.returncode == 0 else f"git diff failed: {result.stderr}")
|
|
return dest
|
|
|
|
|
|
def ensure_commits(pr_number: int, head_sha: str, repo_root: Path) -> None:
|
|
"""Fetch PR commits if not available locally."""
|
|
result = subprocess.run(
|
|
["git", "cat-file", "-t", head_sha],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
timeout=5,
|
|
)
|
|
if result.returncode == 0:
|
|
return
|
|
subprocess.run(
|
|
["git", "fetch", "origin", f"pull/{pr_number}/head"],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
timeout=30,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CommitProvenance:
|
|
"""Agent-authorship evidence parsed from the PR's commit-message trailers."""
|
|
|
|
commit_count: int
|
|
agent_commit_count: int
|
|
generated_by: tuple[str, ...]
|
|
task_ids: tuple[str, ...]
|
|
|
|
@property
|
|
def agent_authored(self) -> bool:
|
|
return self.agent_commit_count > 0
|
|
|
|
|
|
# Record separator keeps multi-paragraph commit messages intact when splitting
|
|
# `git log --format=%B%x1e` output back into one message per commit.
|
|
_COMMIT_RECORD_SEPARATOR = "\x1e"
|
|
|
|
_GENERATED_BY_RE = re.compile(r"^generated-by:[ \t]*(.+?)[ \t]*$", re.IGNORECASE | re.MULTILINE)
|
|
_TASK_ID_RE = re.compile(r"^task-id:[ \t]*(.+?)[ \t]*$", re.IGNORECASE | re.MULTILINE)
|
|
|
|
|
|
def parse_provenance_trailers(log_output: str) -> CommitProvenance:
|
|
"""Parse `Generated-By:` / `Task-Id:` trailers out of `git log --format=%B%x1e` output.
|
|
|
|
Agent tooling stamps these trailers on every commit it authors, so any
|
|
commit carrying one marks the PR as agent-authored. Values are collected
|
|
de-duplicated in first-seen order.
|
|
"""
|
|
messages = [m for m in (raw.strip() for raw in log_output.split(_COMMIT_RECORD_SEPARATOR)) if m]
|
|
agent_commits = 0
|
|
generated_by: list[str] = []
|
|
task_ids: list[str] = []
|
|
for message in messages:
|
|
generators = _GENERATED_BY_RE.findall(message)
|
|
ids = _TASK_ID_RE.findall(message)
|
|
if generators or ids:
|
|
agent_commits += 1
|
|
generated_by.extend(g for g in generators if g not in generated_by)
|
|
task_ids.extend(t for t in ids if t not in task_ids)
|
|
return CommitProvenance(
|
|
commit_count=len(messages),
|
|
agent_commit_count=agent_commits,
|
|
generated_by=tuple(generated_by),
|
|
task_ids=tuple(task_ids),
|
|
)
|
|
|
|
|
|
def pr_provenance(base_sha: str, head_sha: str, repo_root: Path) -> CommitProvenance | None:
|
|
"""Provenance trailers of the PR's own commits, or None when git fails.
|
|
|
|
Reads the head commits (base..head) from the local checkout — never the
|
|
squash-merge commit, which can drop or rewrite trailers depending on repo
|
|
settings. `ensure_commits` has already fetched the head objects by the
|
|
time this runs.
|
|
"""
|
|
cmd = ["git", "log", f"{base_sha}..{head_sha}", f"--format=%B{_COMMIT_RECORD_SEPARATOR}"]
|
|
try:
|
|
result = subprocess.run(cmd, cwd=repo_root, capture_output=True, text=True, timeout=30)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
if result.returncode != 0:
|
|
return None
|
|
return parse_provenance_trailers(result.stdout)
|
|
|
|
|
|
def provenance_evidence(prov: CommitProvenance | None) -> dict | None:
|
|
"""Serialize a CommitProvenance for the output JSON (or null)."""
|
|
if prov is None:
|
|
return None
|
|
return {
|
|
"agent_authored": prov.agent_authored,
|
|
"commit_count": prov.commit_count,
|
|
"agent_commit_count": prov.agent_commit_count,
|
|
"generated_by": list(prov.generated_by),
|
|
"task_ids": list(prov.task_ids),
|
|
}
|
|
|
|
|
|
def fetch_pr(pr_number: int, repo: str, repo_root: Path | None = None) -> PRData:
|
|
"""Fetch PR data: metadata from API, file stats from local git."""
|
|
pr = _gh_api(f"repos/{repo}/pulls/{pr_number}")
|
|
reviews_raw = _gh_api(f"repos/{repo}/pulls/{pr_number}/reviews", paginate=True)
|
|
|
|
# Discussion is advisory context only. Skip it for bot PRs (refused before
|
|
# any prompt is built) and never let a transient gh failure fail the run —
|
|
# an exception here would turn the wait-loop refetch into a spurious WAIT.
|
|
author_is_bot = is_bot_author(pr.get("user", {}))
|
|
discussion: list[dict] = []
|
|
if not author_is_bot:
|
|
try:
|
|
discussion_raw = _gh_api(f"repos/{repo}/issues/{pr_number}/comments", paginate=True)
|
|
discussion = _normalize_discussion_for_prompt(discussion_raw)
|
|
except Exception as exc:
|
|
print(f"warning: discussion fetch failed ({exc}); continuing with no discussion context") # noqa: T201
|
|
|
|
base_sha = pr["base"]["sha"]
|
|
head_sha = pr["head"]["sha"]
|
|
check_runs_resp = _gh_api(f"repos/{repo}/commits/{head_sha}/check-runs")
|
|
|
|
git_root = repo_root or Path.cwd()
|
|
ensure_commits(pr_number, head_sha, git_root)
|
|
files = _git_diff_files(base_sha, head_sha, git_root)
|
|
|
|
review_comments, pr_reactions = _fetch_threads_and_reactions(repo, pr_number, pr["user"]["login"])
|
|
|
|
return PRData(
|
|
number=pr_number,
|
|
repo=repo,
|
|
title=pr["title"],
|
|
state=pr["state"],
|
|
draft=pr.get("draft", False),
|
|
mergeable_state=pr.get("mergeable_state", "unknown"),
|
|
author=pr["user"]["login"],
|
|
labels=[label["name"] for label in pr.get("labels", [])],
|
|
base_sha=base_sha,
|
|
head_sha=head_sha,
|
|
files=files,
|
|
reviews=_normalize_reviews_for_prompt(reviews_raw, head_sha),
|
|
review_comments=review_comments,
|
|
check_runs=check_runs_resp.get("check_runs", []),
|
|
author_is_bot=author_is_bot,
|
|
pr_reactions=pr_reactions,
|
|
body=pr.get("body") or "",
|
|
discussion=discussion,
|
|
)
|
|
|
|
|
|
def check_team_membership(author: str, team_slug: str) -> bool:
|
|
"""Check if author is an active member of the given GitHub team."""
|
|
try:
|
|
result = subprocess.run(
|
|
["gh", "api", f"orgs/Insights/teams/{team_slug}/memberships/{author}"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
if result.returncode == 0:
|
|
return json.loads(result.stdout).get("state") == "active"
|
|
except (subprocess.TimeoutExpired, json.JSONDecodeError):
|
|
pass
|
|
return False
|