Skip to content

Commit 064b286

Browse files
committed
Convert to GitHub actions agentic workflow
1 parent 1b9feca commit 064b286

7 files changed

Lines changed: 988 additions & 516 deletions

File tree

.github/agents/pr-review.agent.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
description: |
3+
Reviews a pull request in opentelemetry-java-instrumentation against the
4+
repository style guide and review knowledge, and emits structured findings
5+
as JSON for a downstream job to post as a GitHub review.
6+
tools: [view, rg, grep, web_fetch]
7+
---
8+
9+
# PR Review persona
10+
11+
You are an automated code reviewer for the
12+
`opentelemetry-java-instrumentation` repository. Your single task this run is
13+
to review one pull request and write your findings as JSON to a fixed file
14+
path. Another job validates and publishes those findings; you do not post the
15+
review yourself.
16+
17+
## Inputs you must read
18+
19+
A deterministic review bundle is staged on disk before you start. The caller's
20+
prompt tells you exactly where it lives. The bundle contains:
21+
22+
- `pr.diff` — the unified diff of the PR. **This is the authoritative source
23+
for what changed.** Only flag issues on right-side lines that appear inside
24+
these hunks.
25+
- `metadata.json` — PR metadata (base/head SHAs, branch names).
26+
- `diff-scope.json` — per-file changed-line and hunk index, for your
27+
reference if you want to double-check scoping.
28+
- `files/<repo-relative-path>` — the post-change contents of every file the
29+
PR modified or added. **Always read PR-changed files from here**, not from
30+
the working tree (the tree is detached at the PR's base commit and does not
31+
contain the PR's changes).
32+
- `knowledge/*.md` — review knowledge articles. Start with `README.md` to
33+
decide which articles apply. Always apply the general rules, the style
34+
guide, and the metadata.yaml guidance.
35+
36+
For files **not** changed by the PR (neighbouring helpers, sibling metadata,
37+
referenced classes), read directly from the working tree using the repo-
38+
relative path. Do **not** prefix those with the bundle path.
39+
40+
For files deleted by the PR, do not attempt to read them — their contents are
41+
intentionally absent.
42+
43+
## Output contract
44+
45+
Write your findings to the JSON path the caller specifies. The file must
46+
contain exactly this shape:
47+
48+
```json
49+
{
50+
"body": "brief review summary",
51+
"comments": [
52+
{
53+
"path": "repo-relative file path",
54+
"line": 123,
55+
"start_line": 120,
56+
"category": "[Style]",
57+
"body": "concise review comment",
58+
"suggestion": "optional exact replacement text"
59+
}
60+
]
61+
}
62+
```
63+
64+
## Hard rules
65+
66+
- Do not switch branches.
67+
- Do not edit any repository file. Your only write is the findings JSON.
68+
- Do not commit. Do not push.
69+
- Only flag issues on changed right-side lines that fall inside a diff hunk
70+
in `pr.diff`. Findings outside the diff scope will be discarded by the
71+
validator.
72+
- Do not flag non-capturing lambdas or method references as "unnecessary
73+
allocations" — the JIT caches them per call site.
74+
- Use `suggestion` text only when the replacement is exact and ready to apply
75+
via GitHub's suggestion UI.
76+
- For a deletion suggestion, set `start_line` and `line` to span the lines to
77+
remove and set `suggestion` to the empty string `""`.
78+
- Return no comments for uncertain or low-confidence observations. Silence is
79+
better than noise.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Shared helpers for the pr-review workflow scripts.
2+
3+
Kept intentionally small: the full pr-triage `common` module includes
4+
branch-restoration, Copilot CLI handoff, and other helpers the review
5+
workflow does not need. This module covers only what the three review
6+
scripts in this directory actually use.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import shlex
13+
import subprocess
14+
import sys
15+
from dataclasses import dataclass, field
16+
from pathlib import Path
17+
from typing import Any
18+
19+
20+
# Force UTF-8 on parent stdout/stderr so unicode characters in subprocess
21+
# output don't crash the default cp1252 codec on Windows.
22+
for _stream in (sys.stdout, sys.stderr):
23+
_reconfigure = getattr(_stream, "reconfigure", None)
24+
if _reconfigure is not None:
25+
_reconfigure(encoding="utf-8", errors="replace")
26+
27+
28+
@dataclass
29+
class Summary:
30+
"""Lightweight bag of side-effect results, threaded through helpers."""
31+
32+
pr: int
33+
pr_url: str | None = None
34+
review_url: str | None = None
35+
temp_dir: str | None = None
36+
notes: list[str] = field(default_factory=list)
37+
38+
39+
def progress(message: str) -> None:
40+
print(f"[review] {message}", flush=True)
41+
42+
43+
def _run(cmd: list[str], summary: Summary | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
44+
if summary is not None:
45+
progress("Running: " + " ".join(shlex.quote(part) for part in cmd))
46+
return subprocess.run(
47+
cmd,
48+
capture_output=True,
49+
text=True,
50+
encoding="utf-8",
51+
errors="replace",
52+
check=check,
53+
)
54+
55+
56+
def gh(args: list[str], summary: Summary | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
57+
return _run(["gh", *args], summary, check)
58+
59+
60+
def gh_json(args: list[str], summary: Summary | None = None) -> Any:
61+
result = gh(args, summary)
62+
return json.loads(result.stdout or "null")
63+
64+
65+
def detect_repo(summary: Summary | None = None) -> str:
66+
return gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], summary).stdout.strip()
67+
68+
69+
def write_json(path: Path, value: Any) -> None:
70+
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")

.github/scripts/pr-review/gate.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
#!/usr/bin/env python3
2+
"""Gate the pr-review workflow: resolve trigger, validate, emit outputs.
3+
4+
Reads trigger context from environment variables, decides whether the agent
5+
should run, and writes outputs to $GITHUB_OUTPUT:
6+
7+
should_run - "true" if the agent should run, else "false"
8+
pr_number - PR number to review
9+
model - resolved Copilot model (default if override invalid)
10+
model_warning - human-readable warning if the requested model was rejected
11+
triggered_by - short string for the review-body footer
12+
base_ref_oid - PR base commit SHA, used by the agent's checkout step
13+
14+
Required env: GH_TOKEN, EVENT_NAME, DEFAULT_MODEL, ALLOWED_MODELS, plus the
15+
trigger-specific variables documented inline.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import os
21+
import re
22+
import sys
23+
from pathlib import Path
24+
25+
from common import gh_json, progress
26+
27+
28+
REVIEW_RE = re.compile(r"^/review(?:\s+(\S+))?\s*$")
29+
30+
31+
def emit(outputs: dict[str, str]) -> None:
32+
path = os.environ.get("GITHUB_OUTPUT")
33+
if not path:
34+
for key, value in outputs.items():
35+
print(f"{key}={value}")
36+
return
37+
with Path(path).open("a", encoding="utf-8") as f:
38+
for key, value in outputs.items():
39+
if "\n" in value:
40+
f.write(f"{key}<<__GATE_EOF__\n{value}\n__GATE_EOF__\n")
41+
else:
42+
f.write(f"{key}={value}\n")
43+
44+
45+
def skip(reason: str) -> int:
46+
progress(f"Gate: {reason} - skipping run.")
47+
emit(
48+
{
49+
"should_run": "false",
50+
"pr_number": "",
51+
"model": "",
52+
"model_warning": "",
53+
"triggered_by": "",
54+
"base_ref_oid": "",
55+
}
56+
)
57+
return 0
58+
59+
60+
def resolve_model(requested: str, default_model: str, allowed_models: str) -> tuple[str, str]:
61+
if not requested:
62+
return default_model, ""
63+
allowed = {m.strip() for m in allowed_models.split(",") if m.strip()}
64+
if requested in allowed:
65+
return requested, ""
66+
return (
67+
default_model,
68+
f"requested model `{requested}` is not in the allowlist; using default `{default_model}`.",
69+
)
70+
71+
72+
def commenter_has_write_access(repo: str, login: str) -> bool:
73+
# gh returns non-zero (404) for users without an explicit collaborator
74+
# entry, which we treat the same as "no write access". This also denies
75+
# on transient gh/API failures, which is the safer default for a gate
76+
# that controls whether the reviewer agent runs.
77+
try:
78+
result = gh_json(
79+
["api", f"repos/{repo}/collaborators/{login}/permission", "-q", ".permission"],
80+
)
81+
except Exception:
82+
return False
83+
# gh_json returns parsed JSON; with -q the output is a bare string.
84+
return result in {"admin", "write"}
85+
86+
87+
class SkipRun(Exception):
88+
"""Raised to abort the gate cleanly with a skip outcome."""
89+
90+
91+
def resolve_trigger(env: dict[str, str]) -> tuple[str, str, str, str]:
92+
"""Return (pr, model, warning, triggered_by). Raises SkipRun to skip."""
93+
event = env.get("EVENT_NAME", "")
94+
default_model = env.get("DEFAULT_MODEL", "")
95+
allowed_models = env.get("ALLOWED_MODELS", "")
96+
repo = env.get("GITHUB_REPOSITORY", "")
97+
98+
if event == "pull_request_target":
99+
pr = env.get("PR_FROM_PR_EVENT", "")
100+
if not pr:
101+
raise SkipRun("no PR number on pull_request_target event")
102+
model, warning = resolve_model("", default_model, allowed_models)
103+
return pr, model, warning, "ready_for_review"
104+
105+
if event == "issue_comment":
106+
pr = env.get("PR_FROM_COMMENT", "")
107+
if not pr:
108+
raise SkipRun("no PR number on issue_comment event")
109+
body = (env.get("COMMENT_BODY", "") or "").strip()
110+
match = REVIEW_RE.match(body)
111+
if not match:
112+
raise SkipRun("comment body does not match /review[ <model>]")
113+
author = env.get("COMMENT_AUTHOR", "")
114+
if not author or not commenter_has_write_access(repo, author):
115+
raise SkipRun(f"commenter @{author} lacks write permission")
116+
requested_model = match.group(1) or ""
117+
model, warning = resolve_model(requested_model, default_model, allowed_models)
118+
return pr, model, warning, f"`/review` by @{author}"
119+
120+
raise SkipRun(f"unsupported event: {event}")
121+
122+
123+
def pr_state(repo: str, pr: str) -> dict | None:
124+
try:
125+
return gh_json(
126+
["pr", "view", pr, "--repo", repo, "--json", "state,baseRefOid,isDraft,number"],
127+
)
128+
except Exception:
129+
return None
130+
131+
132+
def main() -> int:
133+
env = os.environ
134+
repo = env.get("GITHUB_REPOSITORY", "")
135+
136+
try:
137+
pr, model, warning, triggered_by = resolve_trigger(env)
138+
info = pr_state(repo, pr)
139+
if not info:
140+
raise SkipRun(f"PR #{pr} not found")
141+
if info.get("state") != "OPEN":
142+
raise SkipRun(f"PR #{pr} is not open (state={info.get('state')})")
143+
if info.get("isDraft") and env.get("EVENT_NAME") != "issue_comment":
144+
raise SkipRun(f"PR #{pr} is a draft and trigger is {env.get('EVENT_NAME')}")
145+
base_ref_oid = info.get("baseRefOid", "")
146+
except SkipRun as e:
147+
return skip(str(e))
148+
149+
progress(f"Gate accepted: pr={pr} trigger={triggered_by} model={model}")
150+
emit(
151+
{
152+
"should_run": "true",
153+
"pr_number": str(pr),
154+
"model": model,
155+
"model_warning": warning,
156+
"triggered_by": triggered_by,
157+
"base_ref_oid": base_ref_oid,
158+
}
159+
)
160+
return 0
161+
162+
163+
if __name__ == "__main__":
164+
sys.exit(main())

0 commit comments

Comments
 (0)