Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
- Run offline routing, transport, input, and report tests, including the complete
PR-flow integration, with `uv run --project projects/openshell-agent-runner
pytest tests/test_ci_scope.py tests/test_github_api.py tests/test_*review*.py`.
- Keep the live pipeline smoke focused on execution contracts, not expected
- Keep the live integration focused on execution contracts, not expected
reviewer verdicts. It never posts a PR assessment.
- Run CI profile contract tests in the OAR environment.
- See `docs/development/ci.md` for triggers, trust boundaries, and local checks.
3 changes: 2 additions & 1 deletion .github/actions/setup-review-gateway/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ runs:
- name: Install OpenShell
shell: bash
run: |
curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/v0.0.116/install.sh \
# OpenShell v0.0.116, resolved to an immutable commit.
curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/d1155aa70042d3e2ee49dbfa15346b108b7c1d92/install.sh \
| OPENSHELL_VERSION=v0.0.116 sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Wait for the gateway
Expand Down
27 changes: 24 additions & 3 deletions .github/scripts/pr_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import yaml
from ci_scope import new_project_paths, project_task
from github_api import GitHub, GitHubError
from review_report import find_existing_report


def resolve_request(github, context):
Expand All @@ -24,12 +25,13 @@ def resolve_request(github, context):
pr = github.request("GET", f"pulls/{number}")
if (
pr["state"] != "open"
or pr["draft"]
or pr["head"]["sha"] != payload_pr["head"]["sha"]
or (pr["head"].get("repo") or {}).get("full_name") != github.repository
or pr["user"]["login"] == "dependabot[bot]"
):
return None
if pr["draft"]:
return _retirement_request(github, pr)

files = github.paginate(f"pulls/{number}/files?per_page=100")
if len(files) != pr["changed_files"]:
Expand All @@ -56,7 +58,7 @@ def resolve_request(github, context):
except ValueError as error:
errors.append(str(error))
if not tasks and not errors:
return None
return _retirement_request(github, pr)
return {
"number": number,
"head": pr["head"]["sha"],
Expand Down Expand Up @@ -93,7 +95,10 @@ def main():
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(request, indent=2) + "\n", encoding="utf-8")
outputs = {key: request[key] for key in ("number", "head", "base")}
outputs.update(ready=str(not request["reason"]).lower(), tooling=tooling)
outputs.update(
ready=str(bool(request["tasks"]) and not request["reason"]).lower(),
tooling=tooling,
)
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output:
output.writelines(f"{key}={value}\n" for key, value in outputs.items())
if request["reason"]:
Expand Down Expand Up @@ -122,5 +127,21 @@ def _read_metadata(github, path, revision):
raise ValueError(f"Invalid YAML in {filename}.") from error


def _retirement_request(github, pr):
comments = github.paginate(f"issues/{pr['number']}/comments")
if find_existing_report(comments) is None:
return None
return {
"number": pr["number"],
"head": pr["head"]["sha"],
"base": pr["base"]["sha"],
"title": pr["title"],
"description": pr.get("body") or "",
"tasks": [],
"reason": "",
"retire": True,
}


if __name__ == "__main__":
main()
74 changes: 48 additions & 26 deletions .github/scripts/review_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
import os
import re
import sys
from pathlib import Path
from pathlib import Path, PurePosixPath
from urllib.parse import quote

REVIEW_MARKER = "<!-- oar-pr-review -->"
REPORT_AUTHOR = "github-actions[bot]"
VERDICTS = {
"pass": "✅ Pass",
"needs_changes": "⚠️ Needs changes",
Expand Down Expand Up @@ -82,14 +84,15 @@ def read_results(directory, tasks):

def render_report(*, request, reviews, run_url, run_id, outcome):
reason = f" — {_escape_text(request['reason'])}" if request.get("reason") else ""
source_url = f"{run_url.partition('/actions/')[0]}/blob/{request['head']}"
lines = [
REVIEW_MARKER,
f"<!-- oar-report-run:{run_id} -->",
"## New project review",
"",
f"Revision: `{request['head']}` · [Workflow and result artifacts]({run_url})",
"",
"Review findings and scores are advisory. Required checks remain separate merge gates.",
"Review findings are advisory. Required checks remain separate merge gates.",
"",
f"Execution: **{_escape_text(outcome)}**{reason}",
"",
Expand All @@ -101,17 +104,17 @@ def render_report(*, request, reviews, run_url, run_id, outcome):
[
"### Reviews",
"",
"| Project | Verdict | Guidelines | Score | Findings |",
"| --- | --- | --- | ---: | ---: |",
"| Project | Verdict | Guidelines | Findings |",
"| --- | --- | --- | ---: |",
]
)
for review in reviews:
label = _escape_text(review["label"])
result = review.get("result")
lines.append(
f"| {label} | {VERDICTS[result['verdict']]} | {VERDICTS[result['guidelines_assessment']['verdict']]} | {result['overall_score']}/100 | {len(result['findings'])} |"
f"| {label} | {VERDICTS[result['verdict']]} | {VERDICTS[result['guidelines_assessment']['verdict']]} | {len(result['findings'])} |"
if result
else f"| {label} | Not completed | — | — | — |"
else f"| {label} | Not completed | — | — |"
)
for review in reviews:
lines.extend(
Expand All @@ -137,25 +140,19 @@ def render_report(*, request, reviews, run_url, run_id, outcome):
f"**Project guidelines: {VERDICTS[result['guidelines_assessment']['verdict']]}**",
"",
_escape_text(result["guidelines_assessment"]["explanation"]),
"",
"| Criterion | Score | Rationale |",
"| --- | ---: | --- |",
]
)
for item in result["criterion_scores"]:
lines.append(
f"| {_escape_text(item.get('criterion'))} | {item['score']} | {_escape_text(item.get('explanation'))} |"
)
lines.extend(["", "#### Findings", ""])
if not result["findings"]:
lines.append("No actionable findings.")
for finding in result["findings"]:
location = finding.get("path") or review["label"]
line = f":{finding['line']}" if finding.get("line") else ""
location = _finding_location(
finding, review["label"], source_url=source_url
)
evidence = finding["evidence"]
lines.extend(
[
f"- **{_escape_text(finding.get('severity'))}: {_escape_text(finding.get('title'))}** — {_escape_text(location)}{line}",
f"- **{_escape_text(finding.get('severity'))}: {_escape_text(finding.get('title'))}** — {location}",
f" - Evidence: {_escape_text(evidence)}",
f" - Recommendation: {_escape_text(finding.get('recommendation'))}",
]
Expand All @@ -182,32 +179,46 @@ def render_report(*, request, reviews, run_url, run_id, outcome):
def publish_report(github, request, body, run_id):
number = request["number"]
pr = github.request("GET", f"pulls/{number}")
if pr["state"] != "open" or pr["head"]["sha"] != request["head"]:
if (
pr["state"] != "open"
or pr["head"]["sha"] != request["head"]
or (pr.get("draft") and not request.get("retire"))
):
return False
comments = github.paginate(f"issues/{number}/comments")
existing = next(
(
comment
for comment in comments
if (comment.get("user") or {}).get("type") == "Bot"
and REVIEW_MARKER in (comment.get("body") or "")
),
None,
)
existing = find_existing_report(comments)
previous_run = (
re.search(r"<!-- oar-report-run:(\d+) -->", existing["body"])
if existing
else None
)
if previous_run and int(previous_run.group(1)) > int(run_id):
return False
if request.get("retire"):
if not existing:
return False
github.request("DELETE", f"issues/comments/{existing['id']}")
return True
if existing:
github.request("PATCH", f"issues/comments/{existing['id']}", {"body": body})
else:
github.request("POST", f"issues/{number}/comments", {"body": body})
return True


def find_existing_report(comments):
return next(
(
comment
for comment in comments
if (comment.get("user") or {}).get("type") == "Bot"
and (comment.get("user") or {}).get("login") == REPORT_AUTHOR
and REVIEW_MARKER in (comment.get("body") or "")
),
None,
)


def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
Expand Down Expand Up @@ -280,5 +291,16 @@ def _escape_text(value):
return re.sub(r"\r?\n", " ", text)


def _finding_location(finding, fallback, *, source_url):
path = finding.get("path") or fallback
line = finding.get("line")
display = f"{path}:{line}" if line else path
candidate = PurePosixPath(path)
if candidate.is_absolute() or ".." in candidate.parts or str(candidate) != path:
return _escape_text(display)
anchor = f"#L{line}" if line else ""
return f"[{_escape_text(display)}]({source_url}/{quote(path, safe='/')}{anchor})"


if __name__ == "__main__":
sys.exit(main())
Loading
Loading