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
157 changes: 157 additions & 0 deletions .github/workflows/scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Spec Review: Local Developer Guide

Run the spec-review workflow locally via VS Code, shell scripts, or the Copilot CLI.

There are three major paths:

1. **[Use VS Code / Copilot Chat](#use-vs-code--copilot-chat)** (interactive, most flexible)
2. **[Run the shell scripts](#run-single-model-review-spec_reviewsh)** (single or multi-model, automated)
3. **[Use Copilot CLI manually](#use-copilot-cli-manually)** (interactive, most flexible, scriptable)

---

## Use VS Code / Copilot Chat

The easiest way to get quick, interactive feedback is with [GitHub Copilot](https://github.com/features/copilot) in VS Code (or via [Copilot CLI](#use-copilot-cli-manually)). The `spec-review` agent is designed to review spec files and provide feedback in a structured format the same way as the shell scripts, but with the added benefit of a conversational interface where you can ask follow-up questions, request clarifications, or dive deeper into specific issues.

1. Open this repo in VS Code with **GitHub Copilot Chat**.
2. In Copilot Chat, select the **`spec-review`** agent (bottom left corner of the chat window, "Agent", "Ask", "Edit", "Plan", etc.)
3. Prompt example:

```md
Review the azl release spec please.
```

---

## Run single-model review (spec_review.sh)

`spec_review.sh` orchestrates the Copilot agent, writes report/log/kb, and validates JSON. This is a subset of the multi-model script (`spec_review_multi.sh`) which the pipeline uses, but is faster to run.

### Prerequisites

- **Python**: 3.11+ (CI uses 3.12)
- **Node.js + npm** (for `@github/copilot` CLI)
- **GitHub Copilot token**: export `GH_TOKEN` (scope: `copilot:org_member`) or run `gh auth login`
- Optional: `jq` (for JSON pretty-print)

```bash
cd ~/repos/azurelinux
python -m venv .venv && source .venv/bin/activate
pip install -r .github/workflows/scripts/requirements.txt
gh copilot
# OR
npm install -g @github/copilot
copilot --version # verify CLI
```

### Usage

```bash
# Defaults: reviews all *.spec in repo, writes to repo root
# spec_review_report.json, copilot_log.md, spec_review_kb.md

./.github/workflows/scripts/spec_review.sh \
--spec base/comps/azurelinux-release/azurelinux-release.spec \
--spec base/comps/azurelinux-repos/azurelinux-repos.spec

# Validate / inspect
python .github/workflows/scripts/spec_review_schema.py /tmp/spec_review_report.json --all
python .github/workflows/scripts/spec_review_schema.py /tmp/spec_review_report.json --json | jq
```

The script supports additional options (like selecting a model, or using different URLs); run with `--help` for details.

```bash
./.github/workflows/scripts/spec_review.sh --help
```

## Run multi-model review (spec_review_multi.sh)

`spec_review_multi.sh` runs two different LLMs sequentially as independent reviewers, then
uses a third model pass to synthesize their findings into a single high-quality report. This is the flow used in the CI pipeline.

**Why multi-model?**

- Model diversity catches different issues (reduces false negatives)
- Synthesis pass deduplicates and resolves conflicts intelligently
- Higher confidence in findings that both models agree on

```bash
# Defaults: claude-opus-4.6 + gpt-5.2-codex reviewers, gpt-5.2-codex synthesizer
./.github/workflows/scripts/spec_review_multi.sh \
--spec base/comps/azurelinux-release/azurelinux-release.spec

# Custom models
./.github/workflows/scripts/spec_review_multi.sh \
--spec foo.spec \
--model1 gpt-5.2-codex \
--model2 claude-opus-4.6 \
--synth-model gpt-5.2-codex \
--output final_report.json

# View intermediate files for debugging
ls /tmp/spec_review_workdir/
# report_a.json, report_b.json - individual reviewer reports
# kb_a.md, kb_b.md - knowledge bases
# kb_synth.md - synthesis notes
# log_a.md, log_b.md, log_synth.md - copilot session logs
```

Run with `--help` for all options:

```bash
./.github/workflows/scripts/spec_review_multi.sh --help
```

---

## Use Copilot CLI manually (interactive, most flexible, scriptable)

```bash
# Expects the agent file to be in .github/agents/spec-review.agent.md. Can also be
# copied to the host global agents dir (~/.copilot/agents/).

# Interactive run
copilot --agent spec-review
```

```bash
# For semi-automated or fully automated runs, use -i or -p flags:
prompt="Review: base/comps/azurelinux-release/azurelinux-release.spec against packaging guidelines.\n"\
"Write JSON to spec_review_report.json and validate with: python .github/workflows/scripts/spec_review_schema.py spec_review_report.json"

# Semi-interactive (runs prompt, then waits for the user)
copilot --agent spec-review \
--allow-all-tools \
--add-dir "$PWD" \
--allow-url https://docs.fedoraproject.org \
--allow-url https://rpm-packaging-guide.github.io \
--allow-url http://rpm.org \
-i "$prompt"

# NOTE:
# --allow-all-urls can be used instead of individual --allow-url entries,
# but is less reliable since it may allow unwanted URLs.

# Fully automated (runs prompt directly, exits when done)
copilot --agent spec-review \
--allow-all-tools \
--add-dir "$PWD" \
--allow-url https://docs.fedoraproject.org \
--allow-url https://rpm-packaging-guide.github.io \
--allow-url http://rpm.org \
-p "$prompt"

# Review output
python .github/workflows/scripts/spec_review_schema.py spec_review_report.json --all
```

## Future work

- Support generated specfiles from overlays
- Add explicit instruction files for different files (e.g., packaging guidelines, best practices)
- Cache the knowledge base between runs to speed up subsequent reviews and reduce web requests
- De-prioritize upstream issues in the spec file, focus on local changes
- Clearly indicate changes in the current PR
- More tools for the agent (e.g., diffing specs, rpmlint, etc.)
24 changes: 24 additions & 0 deletions .github/workflows/scripts/_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Shared utilities for spec review scripts."""

from pathlib import Path
from typing import Optional


def get_repo_relative_path(spec_file: str, repo_root: Optional[Path] = None) -> str:
"""Convert absolute path to repo-relative path.

Uses repo_root if available; falls back to filename only so that
GitHub annotations/links still have a chance of matching.
"""
spec_path = Path(spec_file)

if not spec_path.is_absolute():
return spec_file

if repo_root:
try:
return str(spec_path.resolve().relative_to(repo_root.resolve()))
except ValueError:
pass

return spec_path.name
132 changes: 132 additions & 0 deletions .github/workflows/scripts/create_check_annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Generate GitHub Check annotations from spec review report.

Usage:
python create_check_annotations.py report.json --workflow-commands
python create_check_annotations.py report.json --json
python create_check_annotations.py report.json --repo-root /path/to/repo
"""

import argparse
import json
import sys
from pathlib import Path
from typing import Optional

from _common import get_repo_relative_path

# Mapping from finding category to (workflow command level, checks API level, checks API title)
_SEVERITY_MAP = {
"errors": ("error", "failure", "Spec Error"),
"warnings": ("warning", "warning", "Spec Warning"),
"suggestions": ("notice", "notice", "Suggestion"),
}


def _iter_findings(report: dict, repo_root: Optional[Path] = None):
"""Yield (spec_file, category, finding) for every finding in the report."""
for review in report.get("spec_reviews", []):
spec_file = get_repo_relative_path(review.get("spec_file", ""), repo_root)
for category in _SEVERITY_MAP:
for finding in review.get(category, []):
yield spec_file, category, finding


def _format_message(finding: dict, escape_fn=None) -> str:
"""Build a message string from a finding, optionally escaping it."""
desc = finding.get("description", "")
citation = finding.get("citation")
if escape_fn:
desc = escape_fn(desc)
msg = desc
if citation and citation not in ("N/A", "n/a", ""):
cite = escape_fn(citation) if escape_fn else citation
msg += f" (Ref: {cite})" if escape_fn else f"\n\nRef: {citation}"
return msg


def escape_workflow_command(s: str) -> str:
"""Escape special characters for GitHub Actions workflow commands.

See https://github.com/actions/toolkit/issues/193
Order matters: % must be escaped first to avoid double-escaping.
"""
return (
s.replace("%", "%25")
.replace("\r", "%0D")
.replace("\n", "%0A")
.replace(":", "%3A")
.replace(",", "%2C")
)


def generate_workflow_commands(report: dict, repo_root: Optional[Path] = None) -> list[str]:
"""Generate GitHub Actions workflow commands for annotations."""
commands = []
for spec_file, category, finding in _iter_findings(report, repo_root):
level = _SEVERITY_MAP[category][0]
line = finding.get("line") or 1
msg = _format_message(finding, escape_fn=escape_workflow_command)
escaped_file = escape_workflow_command(spec_file)
commands.append(f"::{level} file={escaped_file},line={line}::{msg}")
return commands
Comment thread
dmcilvaney marked this conversation as resolved.


def generate_check_annotations(report: dict, repo_root: Optional[Path] = None) -> list[dict]:
"""Generate annotations for GitHub Checks API."""
annotations = []
for spec_file, category, finding in _iter_findings(report, repo_root):
_, api_level, title = _SEVERITY_MAP[category]
line = finding.get("line") or 1
msg = _format_message(finding)
annotations.append({
"path": spec_file,
"start_line": line,
"end_line": line,
"annotation_level": api_level,
"message": msg,
"title": title,
})
Comment thread
dmcilvaney marked this conversation as resolved.
return annotations


def main() -> int:
parser = argparse.ArgumentParser(description="Generate check annotations from spec review")
parser.add_argument("file", type=Path, help="Path to report JSON")
parser.add_argument("--workflow-commands", action="store_true",
help="Output GitHub Actions workflow commands")
parser.add_argument("--json", action="store_true",
help="Output annotations as JSON for Checks API")
parser.add_argument("--repo-root", type=Path, default=None,
help="Repository root for converting absolute paths to relative (default: auto-detect via git)")
args = parser.parse_args()

try:
with open(args.file, encoding="utf-8") as f:
report = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Error: {e}", file=sys.stderr)
return 1

# Use provided repo root, or fall back to cwd
repo_root = args.repo_root or Path.cwd()

if args.workflow_commands:
commands = generate_workflow_commands(report, repo_root)
for cmd in commands:
print(cmd)
elif args.json:
annotations = generate_check_annotations(report, repo_root)
print(json.dumps(annotations, indent=2))
else:
# Default: workflow commands
commands = generate_workflow_commands(report, repo_root)
for cmd in commands:
print(cmd)

return 0


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