Skip to content

Commit d9c9ff8

Browse files
Copilotmrjf
andauthored
Add evergreen agentic workflow for PR health monitoring
Agent-Logs-Url: https://github.com/githubnext/tsessebe/sessions/ddd42cec-905f-4078-8abe-b2c5dad7be71 Co-authored-by: mrjf <180956+mrjf@users.noreply.github.com>
1 parent 29eaf92 commit d9c9ff8

1 file changed

Lines changed: 325 additions & 0 deletions

File tree

.github/workflows/evergreen.md

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
---
2+
description: |
3+
Evergreen — keeps pull requests healthy by automatically fixing merge conflicts
4+
and failing CI checks. Runs on a short schedule, deterministically selects one
5+
PR per run, and gives up after 5 attempts that don't improve the same repo state.
6+
7+
on:
8+
schedule: every 5m
9+
workflow_dispatch:
10+
inputs:
11+
pr_number:
12+
description: "Fix a specific PR by number (bypasses scheduling)"
13+
required: false
14+
type: string
15+
16+
permissions: read-all
17+
18+
timeout-minutes: 30
19+
20+
network:
21+
allowed:
22+
- defaults
23+
- node
24+
25+
safe-outputs:
26+
push-to-pull-request-branch:
27+
target: "*"
28+
max: 3
29+
add-comment:
30+
max: 3
31+
target: "*"
32+
33+
checkout:
34+
fetch: ["*"]
35+
fetch-depth: 0
36+
37+
tools:
38+
github:
39+
toolsets: [repos, pull_requests, issues, actions]
40+
bash: true
41+
repo-memory:
42+
branch-name: memory/evergreen
43+
file-glob: ["*.md"]
44+
45+
imports:
46+
- shared/reporting.md
47+
48+
steps:
49+
- name: Find a PR that needs attention
50+
env:
51+
GITHUB_TOKEN: ${{ github.token }}
52+
GITHUB_REPOSITORY: ${{ github.repository }}
53+
FORCED_PR: ${{ github.event.inputs.pr_number }}
54+
run: |
55+
python3 - << 'PYEOF'
56+
import os, json, re, sys
57+
import urllib.request, urllib.error
58+
59+
token = os.environ.get("GITHUB_TOKEN", "")
60+
repo = os.environ.get("GITHUB_REPOSITORY", "")
61+
forced_pr = os.environ.get("FORCED_PR", "").strip()
62+
63+
repo_memory_dir = "/tmp/gh-aw/repo-memory/evergreen"
64+
output_file = "/tmp/gh-aw/evergreen.json"
65+
os.makedirs("/tmp/gh-aw", exist_ok=True)
66+
67+
MAX_ATTEMPTS = 5
68+
69+
def api_get(url):
70+
"""Make an authenticated GET request to the GitHub API."""
71+
req = urllib.request.Request(url, headers={
72+
"Authorization": f"token {token}",
73+
"Accept": "application/vnd.github.v3+json",
74+
})
75+
with urllib.request.urlopen(req, timeout=30) as resp:
76+
return json.loads(resp.read().decode())
77+
78+
def get_all_open_prs():
79+
"""Fetch all open PRs, paginated."""
80+
prs = []
81+
page = 1
82+
while True:
83+
url = f"https://api.github.com/repos/{repo}/pulls?state=open&per_page=100&page={page}&sort=number&direction=asc"
84+
batch = api_get(url)
85+
if not batch:
86+
break
87+
prs.extend(batch)
88+
if len(batch) < 100:
89+
break
90+
page += 1
91+
return prs
92+
93+
def get_check_status(pr):
94+
"""Get combined CI check status for a PR's head commit."""
95+
head_sha = pr["head"]["sha"]
96+
url = f"https://api.github.com/repos/{repo}/commits/{head_sha}/status"
97+
try:
98+
status = api_get(url)
99+
return status.get("state", "unknown")
100+
except Exception as e:
101+
print(f" Warning: could not fetch status for PR #{pr['number']}: {e}")
102+
return "unknown"
103+
104+
def get_check_runs(pr):
105+
"""Get check runs for a PR's head commit."""
106+
head_sha = pr["head"]["sha"]
107+
url = f"https://api.github.com/repos/{repo}/commits/{head_sha}/check-runs"
108+
try:
109+
data = api_get(url)
110+
return data.get("check_runs", [])
111+
except Exception as e:
112+
print(f" Warning: could not fetch check runs for PR #{pr['number']}: {e}")
113+
return []
114+
115+
def read_attempt_state(pr_number):
116+
"""Read attempt tracking state from repo-memory."""
117+
state_file = os.path.join(repo_memory_dir, f"pr-{pr_number}.md")
118+
if not os.path.isfile(state_file):
119+
return {"attempts": 0, "head_sha": None}
120+
with open(state_file, encoding="utf-8") as f:
121+
content = f.read()
122+
state = {"attempts": 0, "head_sha": None}
123+
m = re.search(r'\|\s*head_sha\s*\|\s*(\S+)\s*\|', content)
124+
if m:
125+
state["head_sha"] = m.group(1)
126+
m = re.search(r'\|\s*attempts\s*\|\s*(\d+)\s*\|', content)
127+
if m:
128+
state["attempts"] = int(m.group(1))
129+
return state
130+
131+
def pr_needs_attention(pr):
132+
"""Check if a PR has merge conflicts or failing CI. Returns a list of issues."""
133+
issues = []
134+
135+
# Check mergeable state
136+
# Need to fetch full PR details for mergeable info
137+
pr_url = f"https://api.github.com/repos/{repo}/pulls/{pr['number']}"
138+
try:
139+
full_pr = api_get(pr_url)
140+
mergeable = full_pr.get("mergeable")
141+
mergeable_state = full_pr.get("mergeable_state", "unknown")
142+
if mergeable is False:
143+
issues.append("merge_conflict")
144+
elif mergeable_state == "dirty":
145+
issues.append("merge_conflict")
146+
except Exception as e:
147+
print(f" Warning: could not fetch mergeable state for PR #{pr['number']}: {e}")
148+
149+
# Check CI status via check runs
150+
check_runs = get_check_runs(pr)
151+
failed_checks = []
152+
for cr in check_runs:
153+
conclusion = cr.get("conclusion")
154+
status = cr.get("status")
155+
name = cr.get("name", "unknown")
156+
if conclusion in ("failure", "timed_out", "action_required"):
157+
failed_checks.append(name)
158+
elif status == "completed" and conclusion not in ("success", "neutral", "skipped"):
159+
if conclusion is not None:
160+
failed_checks.append(name)
161+
if failed_checks:
162+
issues.append(f"failing_checks: {', '.join(failed_checks)}")
163+
164+
# Also check commit status API (some checks use the older status API)
165+
combined_status = get_check_status(pr)
166+
if combined_status == "failure":
167+
if not failed_checks:
168+
issues.append("failing_status")
169+
170+
return issues
171+
172+
# --- Main logic ---
173+
174+
print("=== Evergreen PR Health Check ===")
175+
print(f"Repository: {repo}")
176+
177+
prs = get_all_open_prs()
178+
print(f"Found {len(prs)} open PR(s)")
179+
180+
if not prs:
181+
print("No open PRs. Exiting.")
182+
with open(output_file, "w") as f:
183+
json.dump({"selected": None, "reason": "no_open_prs"}, f)
184+
sys.exit(1)
185+
186+
# Evaluate each PR deterministically (sorted by PR number ascending)
187+
candidates = []
188+
skipped = []
189+
190+
# If a specific PR is forced, only check that one
191+
if forced_pr:
192+
prs = [pr for pr in prs if str(pr["number"]) == forced_pr]
193+
if not prs:
194+
print(f"ERROR: PR #{forced_pr} not found among open PRs.")
195+
sys.exit(1)
196+
print(f"FORCED: checking only PR #{forced_pr}")
197+
198+
for pr in sorted(prs, key=lambda p: p["number"]):
199+
pr_num = pr["number"]
200+
head_sha = pr["head"]["sha"]
201+
print(f"\nChecking PR #{pr_num}: {pr['title'][:60]}...")
202+
print(f" Head SHA: {head_sha[:12]}")
203+
204+
issues = pr_needs_attention(pr)
205+
if not issues:
206+
print(f" Status: healthy (no issues)")
207+
continue
208+
209+
print(f" Issues: {issues}")
210+
211+
# Check attempt tracking
212+
attempt_state = read_attempt_state(pr_num)
213+
if attempt_state["head_sha"] == head_sha:
214+
attempts = attempt_state["attempts"]
215+
print(f" Attempts on this SHA: {attempts}/{MAX_ATTEMPTS}")
216+
if attempts >= MAX_ATTEMPTS:
217+
skipped.append({
218+
"pr": pr_num,
219+
"reason": f"max attempts ({MAX_ATTEMPTS}) reached on SHA {head_sha[:12]}",
220+
})
221+
print(f" SKIPPED: max attempts reached")
222+
continue
223+
else:
224+
attempts = 0
225+
print(f" New SHA detected — resetting attempt counter")
226+
227+
candidates.append({
228+
"pr_number": pr_num,
229+
"title": pr["title"],
230+
"head_sha": head_sha,
231+
"base_branch": pr["base"]["ref"],
232+
"head_branch": pr["head"]["ref"],
233+
"issues": issues,
234+
"attempts": attempts,
235+
})
236+
237+
# Select the first candidate (lowest PR number — deterministic)
238+
selected = candidates[0] if candidates else None
239+
240+
result = {
241+
"selected": selected,
242+
"skipped": skipped,
243+
"total_open_prs": len(prs),
244+
"candidates_found": len(candidates),
245+
}
246+
247+
with open(output_file, "w") as f:
248+
json.dump(result, f, indent=2)
249+
250+
if selected:
251+
print(f"\n>>> Selected PR #{selected['pr_number']}: {selected['title']}")
252+
print(f" Issues: {selected['issues']}")
253+
print(f" Attempt: {selected['attempts'] + 1}/{MAX_ATTEMPTS}")
254+
else:
255+
print("\nNo PRs need attention. Exiting.")
256+
sys.exit(1)
257+
PYEOF
258+
259+
features:
260+
copilot-requests: true
261+
---
262+
263+
# Evergreen — PR Health Keeper
264+
265+
You are the Evergreen agent. Your job is to fix pull requests that have merge conflicts or failing CI checks.
266+
267+
## Context
268+
269+
A pre-flight step has already identified a PR that needs attention. Read the selection data from `/tmp/gh-aw/evergreen.json` to understand which PR to fix and what issues it has.
270+
271+
## Workflow
272+
273+
1. **Read the selection file** at `/tmp/gh-aw/evergreen.json`. It contains:
274+
- `selected.pr_number` — the PR to fix
275+
- `selected.issues` — list of problems (e.g., `"merge_conflict"`, `"failing_checks: Test & Lint"`)
276+
- `selected.head_sha` — current HEAD of the PR branch
277+
- `selected.head_branch` — the PR's branch name
278+
- `selected.base_branch` — the target branch (usually `main`)
279+
- `selected.attempts` — how many times we've already tried on this SHA
280+
281+
2. **Check out the PR branch** and investigate the issues.
282+
283+
3. **Fix the issues**:
284+
285+
### Merge Conflicts
286+
- Merge the base branch (`main`) into the PR branch
287+
- Resolve any conflicts intelligently by understanding the intent of both sides
288+
- If the PR is from an autoloop branch, prefer the PR's changes for feature code and main's changes for infrastructure/config
289+
- Run tests after resolving to make sure the merge is clean
290+
291+
### Failing CI Checks
292+
- Read the failing check logs using GitHub tools
293+
- Identify the root cause (test failures, lint errors, type errors, build failures)
294+
- Fix the code on the PR branch
295+
- Run the relevant checks locally to verify the fix before pushing
296+
297+
4. **Push the fix** to the PR branch using the `push-to-pull-request-branch` safe output.
298+
299+
5. **Update attempt tracking** by writing to repo-memory. Write a file to the repo-memory directory at `/tmp/gh-aw/repo-memory/evergreen/pr-{number}.md` with this format:
300+
301+
```markdown
302+
# Evergreen: PR #{number}
303+
304+
## State
305+
306+
| Field | Value |
307+
|:---|:---|
308+
| head_sha | {sha_after_push} |
309+
| attempts | {new_attempt_count} |
310+
| last_run | {ISO 8601 timestamp} |
311+
| last_result | {success or failure} |
312+
```
313+
314+
- If you **pushed a fix**, set `head_sha` to the new SHA (post-push), reset `attempts` to `0`, and set `last_result` to `success`.
315+
- If you **could not fix** the issue, keep the original `head_sha`, increment `attempts` by 1, and set `last_result` to `failure`.
316+
317+
6. **Add a comment** on the PR summarizing what you did (or why you couldn't fix it).
318+
319+
## Rules
320+
321+
- **Be surgical**: make the minimum changes needed to fix the issue. Do not refactor, improve, or add features.
322+
- **Don't break things**: always run tests/lint/typecheck locally before pushing.
323+
- **Give up gracefully**: if you cannot fix the issue after investigating, update the attempt counter and leave a comment explaining what went wrong. Do not force-push or make destructive changes.
324+
- **One PR per run**: only fix the selected PR. Do not touch other PRs.
325+
- **Respect the 5-attempt limit**: the pre-flight step will stop selecting this PR once attempts reach 5 on the same HEAD SHA. If the SHA changes (someone else pushes), the counter resets.

0 commit comments

Comments
 (0)