Skip to content

Commit b418602

Browse files
authored
Merge pull request #30 from randlee/develop
build: Update version to 0.7.0
2 parents e3ed27d + c537245 commit b418602

File tree

10 files changed

+503
-10
lines changed

10 files changed

+503
-10
lines changed

.claude/ci-automation.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# CI Automation Configuration for roslyn-diff
2+
# .NET 10 project with xUnit tests
3+
4+
upstream_branch: main
5+
build_command: dotnet build --configuration Release
6+
test_command: dotnet test --configuration Release --no-build
7+
warn_patterns:
8+
- "warning CS"
9+
- "warning NU"
10+
allow_warnings: false
11+
auto_fix_enabled: true
12+
repo_root: .
13+
14+
# Stack detection
15+
stack: dotnet
16+
solution_file: roslyn-diff.sln

.claude/commands/git-pr.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
allowed-tools: Bash(python3 .claude/scripts/git-pr-status.py*)
3+
name: git-pr
4+
version: 0.6.0
5+
description: Show outstanding PRs and their CI status in a formatted table.
6+
---
7+
8+
# /git-pr command
9+
10+
Display GitHub PR status for this repository.
11+
12+
## Usage
13+
```
14+
/git-pr [--open|--all|--merged] [--fix [instructions]]
15+
```
16+
17+
## Context
18+
19+
- PR status: !`python3 .claude/scripts/git-pr-status.py $ARGUMENTS`
20+
21+
## Flags
22+
- `--open` (default): Show open PRs only
23+
- `--all`: Show all PRs (open, merged, closed)
24+
- `--merged`: Show recently merged PRs
25+
- `--fix`: If PR is failing, automatically attempt to fix (can include additional instructions)
26+
- Any other text after the flags is ignored by the script but remains visible to Claude for context.
27+
28+
## Instructions
29+
30+
1. Use the PR status table from the Context section. Only run `python3 .claude/scripts/git-pr-status.py [flags]` if the Context is missing.
31+
2. Process the output according to the Response Handling rules below
32+
33+
## Response Handling
34+
35+
After receiving the PR table, follow these rules:
36+
37+
### All PRs Passing
38+
If all open PRs show passing CI (✅):
39+
1. Output: `All PRs passing.`
40+
2. If you know which PR is currently active (from conversation context, current branch, or recent work), include the full GitHub URL: `https://github.com/{owner}/{repo}/pull/{number}`
41+
42+
### PR Failing (no --fix flag)
43+
If any PR shows failing CI (❌) and no `--fix` flag was provided:
44+
1. Output: `PR #{number} failing. Investigating...`
45+
2. If you know which PR is active/relevant, launch a **background** `ci-root-cause-agent` to analyze the failure
46+
3. If you do NOT know which PR is active, ask the user which PR to investigate
47+
48+
### PR Failing (with --fix flag)
49+
If any PR shows failing CI (❌) and `--fix` flag was provided:
50+
1. Output: `PR #{number} failing. Attempting fix...`
51+
2. If you know which PR is active/relevant, launch a **background** `ci-fix-agent` to fix the issue
52+
3. Include any user-provided instructions after `--fix` in the agent prompt
53+
4. If you do NOT know which PR is active, ask the user which PR to fix
54+
55+
### Unknown Active PR
56+
If you cannot determine which PR the user is working on:
57+
- Do NOT launch background agents automatically
58+
- Ask the user: "Which PR would you like me to investigate/fix?"
59+
60+
## Manual Actions
61+
62+
You can also:
63+
1. **Check a specific PR**: `/git-pr 29` - Show details for PR #29
64+
2. **Check CI failures**: Run `python3 .claude/scripts/ci-failure-summary.py <PR#>` for detailed failure analysis
65+
3. **Merge a PR**: `gh pr merge <PR#> --squash`
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
#!/usr/bin/env python3
2+
"""
3+
CI Failure Summary Script
4+
Extracts concise failure information from GitHub Actions logs.
5+
Usage: python ci-failure-summary.py <PR_NUMBER|RUN_ID> [--verbose]
6+
"""
7+
8+
import json
9+
import re
10+
import subprocess
11+
import sys
12+
from dataclasses import dataclass
13+
14+
15+
# ANSI colors
16+
RED = "\033[0;31m"
17+
YELLOW = "\033[1;33m"
18+
GREEN = "\033[0;32m"
19+
CYAN = "\033[0;36m"
20+
BOLD = "\033[1m"
21+
NC = "\033[0m"
22+
23+
24+
@dataclass
25+
class FailureInfo:
26+
test_failures: list[str]
27+
assertion_details: list[str]
28+
build_errors: list[str]
29+
timeouts: list[str]
30+
exceptions: list[str]
31+
32+
33+
def run_gh(args: list[str]) -> str:
34+
"""Run a gh CLI command and return output."""
35+
try:
36+
result = subprocess.run(
37+
["gh"] + args,
38+
capture_output=True,
39+
text=True,
40+
check=True
41+
)
42+
return result.stdout
43+
except subprocess.CalledProcessError as e:
44+
return e.stdout or ""
45+
except FileNotFoundError:
46+
print("Error: gh CLI not found", file=sys.stderr)
47+
sys.exit(1)
48+
49+
50+
def parse_failures(log: str) -> FailureInfo:
51+
"""Parse failure log and extract relevant information."""
52+
lines = log.split("\n")
53+
54+
# Test failures
55+
test_pattern = re.compile(r"Failed\s+\S+|✗.*\[FAIL\]|\[xUnit.*\].*Failed", re.IGNORECASE)
56+
test_failures = [
57+
clean_line(line) for line in lines
58+
if test_pattern.search(line)
59+
][:20]
60+
61+
# Assertion details
62+
assertion_pattern = re.compile(
63+
r"Expected.*to be|Expected.*but found|expected.*got|should be.*but|difference of",
64+
re.IGNORECASE
65+
)
66+
assertion_details = [
67+
clean_line(line) for line in lines
68+
if assertion_pattern.search(line) and "Passed" not in line
69+
][:15]
70+
71+
# Build errors
72+
build_pattern = re.compile(r"error CS\d+:|error NU\d+:|error MSB\d+:")
73+
build_errors = [
74+
clean_line(line) for line in lines
75+
if build_pattern.search(line)
76+
][:10]
77+
78+
# Timeouts
79+
timeout_pattern = re.compile(r"timeout|timed out|exceeded|too slow", re.IGNORECASE)
80+
fail_pattern = re.compile(r"fail|error|exceed", re.IGNORECASE)
81+
timeouts = [
82+
clean_line(line) for line in lines
83+
if timeout_pattern.search(line) and fail_pattern.search(line) and "Passed" not in line
84+
][:10]
85+
86+
# Exceptions
87+
exception_pattern = re.compile(r"Exception:|Error:|System\.\w+Exception")
88+
exceptions = [
89+
clean_line(line) for line in lines
90+
if exception_pattern.search(line) and not line.strip().startswith("at ")
91+
][:10]
92+
93+
return FailureInfo(
94+
test_failures=test_failures,
95+
assertion_details=assertion_details,
96+
build_errors=build_errors,
97+
timeouts=timeouts,
98+
exceptions=exceptions
99+
)
100+
101+
102+
def clean_line(line: str) -> str:
103+
"""Remove timestamps and job prefixes from log line."""
104+
# Remove tab-prefixed content
105+
if "\t" in line:
106+
line = line.split("\t")[-1]
107+
# Remove ISO timestamps
108+
line = re.sub(r"^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*", "", line)
109+
return line.strip()
110+
111+
112+
def print_section(title: str, items: list[str], color: str = YELLOW, item_color: str = RED):
113+
"""Print a section with items."""
114+
print(f"\n{color}── {title} ──{NC}")
115+
if items:
116+
for item in items:
117+
print(f" {item_color}{NC} {item}")
118+
else:
119+
print(f" {GREEN}No {title.lower()} found{NC}")
120+
121+
122+
def main():
123+
if len(sys.argv) < 2:
124+
print(f"{RED}Usage: {sys.argv[0]} <PR_NUMBER|RUN_ID> [--verbose]{NC}")
125+
print(" PR_NUMBER: e.g., 29")
126+
print(" RUN_ID: e.g., 12345678")
127+
sys.exit(1)
128+
129+
input_arg = sys.argv[1]
130+
verbose = "--verbose" in sys.argv
131+
132+
# Determine if input is PR number or run ID
133+
run_id = None
134+
if input_arg.isdigit() and len(input_arg) < 6:
135+
# Likely a PR number
136+
pr_num = input_arg
137+
print(f"{CYAN}=== CI Failure Summary for PR #{pr_num} ==={NC}\n")
138+
139+
# Get PR info
140+
pr_json = run_gh(["pr", "view", pr_num, "--json", "title,headRefName,baseRefName,state"])
141+
try:
142+
pr_info = json.loads(pr_json)
143+
print(f"{BOLD}PR:{NC} {pr_info.get('title', 'Unknown')}")
144+
print(f"{BOLD}Branch:{NC} {pr_info.get('headRefName', '?')}{pr_info.get('baseRefName', '?')}\n")
145+
146+
# Get check status
147+
print(f"{BOLD}Check Status:{NC}")
148+
checks_output = run_gh(["pr", "checks", pr_num])
149+
for line in checks_output.strip().split("\n"):
150+
if "fail" in line.lower() or "X" in line:
151+
print(f" {RED}{NC} {line}")
152+
elif "pass" in line.lower() or "✓" in line:
153+
print(f" {GREEN}{NC} {line}")
154+
else:
155+
print(f" {line}")
156+
print()
157+
158+
# Get the failed run ID
159+
head_branch = pr_info.get("headRefName", "")
160+
runs_json = run_gh(["run", "list", "--branch", head_branch, "--limit", "1", "--json", "databaseId,conclusion"])
161+
runs = json.loads(runs_json)
162+
if runs:
163+
run_id = str(runs[0].get("databaseId"))
164+
except json.JSONDecodeError:
165+
print(f"{YELLOW}Could not parse PR info{NC}")
166+
else:
167+
run_id = input_arg
168+
print(f"{CYAN}=== CI Failure Summary for Run #{run_id} ==={NC}\n")
169+
170+
if not run_id:
171+
print(f"{YELLOW}No recent runs found{NC}")
172+
return
173+
174+
# Get run info
175+
run_json = run_gh(["run", "view", run_id, "--json", "conclusion,status,jobs"])
176+
try:
177+
run_info = json.loads(run_json)
178+
conclusion = run_info.get("conclusion") or "in_progress"
179+
status = run_info.get("status", "unknown")
180+
print(f"{BOLD}Run #{run_id}:{NC} {status} ({conclusion})\n")
181+
182+
# List failed jobs
183+
print(f"{BOLD}Failed Jobs:{NC}")
184+
jobs = run_info.get("jobs", [])
185+
failed_jobs = [j.get("name") for j in jobs if j.get("conclusion") == "failure"]
186+
if failed_jobs:
187+
for job in failed_jobs:
188+
print(f" {RED}{NC} {job}")
189+
else:
190+
print(f" {GREEN}No failed jobs{NC}")
191+
print()
192+
except json.JSONDecodeError:
193+
pass
194+
195+
# Get failed logs
196+
print(f"{BOLD}Failure Details:{NC}")
197+
failed_log = run_gh(["run", "view", run_id, "--log-failed"])
198+
199+
if not failed_log.strip():
200+
print(f" {GREEN}No failure logs available{NC}")
201+
return
202+
203+
if verbose:
204+
log_path = "/tmp/ci-failure-raw.log"
205+
with open(log_path, "w") as f:
206+
f.write(failed_log)
207+
print(f" {CYAN}Raw log saved to {log_path}{NC}")
208+
209+
# Parse and display failures
210+
failures = parse_failures(failed_log)
211+
212+
print_section("Test Failures", failures.test_failures)
213+
print_section("Assertion Details", failures.assertion_details, item_color=NC)
214+
print_section("Build Errors", failures.build_errors)
215+
print_section("Timeouts/Performance", failures.timeouts, item_color=YELLOW)
216+
print_section("Exceptions", failures.exceptions)
217+
218+
print(f"\n{CYAN}=== End Summary ==={NC}")
219+
220+
221+
if __name__ == "__main__":
222+
main()

.claude/scripts/git-pr-hook.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env python3
2+
"""
3+
PreToolUse hook for /git-pr command.
4+
Validates that the git-pr-status command is properly formed.
5+
"""
6+
import json
7+
import re
8+
import sys
9+
10+
11+
def main() -> int:
12+
# Read PreToolUse hook payload from stdin
13+
try:
14+
payload = json.load(sys.stdin)
15+
except json.JSONDecodeError:
16+
return 0 # Not a valid payload, allow tool
17+
18+
# Get the bash command being executed
19+
tool_input = payload.get("tool_input") or {}
20+
command = tool_input.get("command", "")
21+
22+
# Check if this is a git-pr-status command
23+
if "git-pr-status.py" not in command:
24+
return 0 # Not our command, allow tool
25+
26+
# Valid git-pr-status command, allow bash to execute
27+
return 0
28+
29+
30+
if __name__ == "__main__":
31+
sys.exit(main())

0 commit comments

Comments
 (0)