mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
254 lines
10 KiB
Bash
Executable file
254 lines
10 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# detect-workflow-approval-state-change.sh
|
|
# Emits latest-workflow-approval-state-change.md — the delta detector for the
|
|
# fork-workflow approval blocker on PR #14297.
|
|
#
|
|
# Unlike the approval-brief (snapshot) and approval-trigger (nudge packet), this
|
|
# script detects STATE TRANSITIONS in the GitHub Actions check suites and check
|
|
# runs. It is the automation that answers: "did the blocker just clear?"
|
|
#
|
|
# Exits 0 with BLOCKER_CLEARED when approval happened and CI is running.
|
|
# Exits 0 with BLOCKER_PERSISTS when action_required suites are still stuck.
|
|
# Exits 1 on API errors (fail-closed — do not assume approval on error).
|
|
#
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
KIT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
ARTIFACT_DIR="$KIT_DIR/artifacts"
|
|
mkdir -p "$ARTIFACT_DIR"
|
|
|
|
TIMESTAMP="$(date +%Y-%m-%dT%H-%M-%S%z)"
|
|
REPORT_PATH="$ARTIFACT_DIR/workflow-approval-state-change-$TIMESTAMP.md"
|
|
LATEST_PATH="$ARTIFACT_DIR/latest-workflow-approval-state-change.md"
|
|
|
|
python - "$REPORT_PATH" "$LATEST_PATH" <<'PY'
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import urllib.request
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
report_path = Path(sys.argv[1])
|
|
latest_path = Path(sys.argv[2])
|
|
artifacts_dir = latest_path.parent # derived: artifacts_dir is the parent of latest_path
|
|
base = 'https://api.github.com/repos/NousResearch/hermes-agent'
|
|
headers = {
|
|
'Accept': 'application/vnd.github+json',
|
|
'User-Agent': 'Hermes-Agent',
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
}
|
|
|
|
token = os.environ.get('GITHUB_TOKEN')
|
|
if not token:
|
|
creds_path = Path.home() / '.git-credentials'
|
|
if creds_path.exists():
|
|
for line in creds_path.read_text().splitlines():
|
|
if 'github.com' not in line or '@github.com' not in line or ':' not in line:
|
|
continue
|
|
token = line.split('://', 1)[1].rsplit('@github.com', 1)[0].split(':', 1)[1]
|
|
break
|
|
if token:
|
|
headers['Authorization'] = f'token {token}'
|
|
|
|
def get(url: str):
|
|
req = urllib.request.Request(url, headers=headers)
|
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
return json.loads(resp.read().decode())
|
|
|
|
pr = get(base + '/pulls/14297')
|
|
sha = pr['head']['sha']
|
|
combined_status = get(base + f'/commits/{sha}/status')
|
|
check_runs = get(base + f'/commits/{sha}/check-runs')
|
|
check_suites = get(base + f'/commits/{sha}/check-suites')
|
|
issue_comments = get(base + '/issues/14297/comments?per_page=100')
|
|
|
|
already_posted_nudge = any(
|
|
(comment.get('user') or {}).get('login') == 'NplusM420'
|
|
and 'Maintainer unblock request for PR #14297' in (comment.get('body') or '')
|
|
for comment in issue_comments
|
|
)
|
|
|
|
action_required_suites = [
|
|
suite for suite in check_suites.get('check_suites', [])
|
|
if suite.get('conclusion') == 'action_required'
|
|
]
|
|
|
|
# Key signals we track
|
|
action_required_count = len(action_required_suites)
|
|
check_runs_total = check_runs.get('total_count', 0)
|
|
combined_state = combined_status.get('state', 'unknown')
|
|
completed_suites = [
|
|
s for s in check_suites.get('check_suites', [])
|
|
if s.get('conclusion') not in (None, 'action_required')
|
|
]
|
|
|
|
# Read the previous run's *current* state from the latest artifact if it exists.
|
|
# That file is the last emitted snapshot, so its current-state section is the right
|
|
# baseline for transition detection on the next run.
|
|
prev_action_required = None
|
|
prev_check_runs = None
|
|
prev_sha = None
|
|
prev_base_sha = None
|
|
|
|
if latest_path.exists():
|
|
import re
|
|
|
|
content = latest_path.read_text(encoding='utf-8')
|
|
for raw_line in content.splitlines():
|
|
line = raw_line.lstrip('- ').strip()
|
|
if line.startswith('Head SHA:'):
|
|
prev_sha = line.split(':', 1)[1].strip().strip('*`')
|
|
elif line.startswith('Action_required suites:'):
|
|
match = re.search(r'(\d+)', line)
|
|
if match:
|
|
prev_action_required = int(match.group(1))
|
|
elif line.startswith('Check runs:'):
|
|
match = re.search(r'(\d+)', line)
|
|
if match:
|
|
prev_check_runs = int(match.group(1))
|
|
elif line.startswith('Base SHA:'):
|
|
prev_base_sha = line.split(':', 1)[1].strip().strip('*`')
|
|
|
|
# Also read the last known base SHA from the branch-refresh artifact so we can
|
|
# detect drift even when the state-change file has never recorded a base SHA.
|
|
import re as re2
|
|
last_refresh_base = None
|
|
refresh_artifact = artifacts_dir / 'latest-reviewer-handoff.md'
|
|
if refresh_artifact.exists():
|
|
for raw_line in refresh_artifact.read_text(encoding='utf-8').splitlines():
|
|
line = raw_line.lstrip('- ').strip()
|
|
if 'Current PR base SHA:' in line or line.startswith('Base SHA:'):
|
|
last_refresh_base = line.split(':', 1)[1].strip().strip('*`')
|
|
break
|
|
|
|
current_base_sha = pr.get('base', {}).get('sha') or pr.get('base', {}).get('ref', '')
|
|
# Use the more-precise baseline: refresh artifact > state-change previous run
|
|
baseline_base_sha = last_refresh_base or prev_base_sha
|
|
base_branch_advanced = (
|
|
baseline_base_sha is not None
|
|
and current_base_sha != baseline_base_sha
|
|
)
|
|
|
|
# Determine state change
|
|
blocker_cleared = (
|
|
action_required_count == 0
|
|
and check_runs_total > 0
|
|
and prev_action_required is not None
|
|
and prev_action_required > 0
|
|
)
|
|
blocker_persists = action_required_count > 0
|
|
|
|
# Transitions
|
|
approval_transition = (
|
|
prev_action_required is not None
|
|
and prev_action_required > 0
|
|
and action_required_count == 0
|
|
)
|
|
ci_started_transition = (
|
|
prev_check_runs is not None
|
|
and prev_check_runs == 0
|
|
and check_runs_total > 0
|
|
)
|
|
base_branch_drift_transition = base_branch_advanced
|
|
|
|
# Status text
|
|
if base_branch_drift_transition:
|
|
verdict = '**BASE_BRANCH_ADVANCED**'
|
|
verdict_detail = (
|
|
f'origin/main has advanced past the base SHA recorded at last refresh ({baseline_base_sha[:8]}… → {current_base_sha[:8]}…). '
|
|
'While the PR head is unchanged, the base is now stale. '
|
|
'If maintainer approval eventually comes through with a stale base, the PR will be non-mergeable. '
|
|
'Rerun the branch-refresh operation before re-triggering CI.'
|
|
)
|
|
elif blocker_cleared:
|
|
verdict = '**BLOCKER_CLEARED**'
|
|
verdict_detail = (
|
|
'Maintainer approved the fork PR workflows. '
|
|
f'{check_runs_total} check run(s) now exist and action_required suites are gone.'
|
|
)
|
|
elif approval_transition and check_runs_total == 0:
|
|
verdict = '**APPROVAL_BUT_CI_NOT_STARTED**'
|
|
verdict_detail = (
|
|
f'Maintainer cleared the {prev_action_required} action_required suite(s), '
|
|
'but no check runs have appeared yet. Re-run emit-pr-review-monitor.sh '
|
|
'and emit-ci-result-interpreter.sh to track CI startup.'
|
|
)
|
|
elif ci_started_transition:
|
|
verdict = '**CI_STARTED**'
|
|
verdict_detail = (
|
|
f'Check runs appeared: {prev_check_runs} → {check_runs_total}. '
|
|
'The blocker shifted from approval to CI interpretation. '
|
|
'Rerun emit-ci-result-interpreter.sh to get the first-CI decision surface.'
|
|
)
|
|
elif blocker_persists:
|
|
verdict = '**BLOCKER_PERSISTS**'
|
|
verdict_detail = (
|
|
f'{action_required_count} action_required suite(s) still present. '
|
|
'Maintainer approval is still the blocker. '
|
|
'No state change since last run.'
|
|
)
|
|
else:
|
|
verdict = '**NO_CHANGE**'
|
|
verdict_detail = (
|
|
'No relevant state change detected. '
|
|
f'Action_required suites: {action_required_count}, check runs: {check_runs_total}.'
|
|
)
|
|
|
|
now = datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')
|
|
report = f"""# Delegation Readiness Doctor — Workflow Approval State Change
|
|
|
|
Generated: {now}
|
|
PR: https://github.com/NousResearch/hermes-agent/pull/14297
|
|
Head SHA: `{sha}`
|
|
|
|
## Verdict
|
|
{verdict}
|
|
|
|
{verdict_detail}
|
|
|
|
---
|
|
|
|
## Current state
|
|
- Action_required suites: **{action_required_count}**
|
|
- Check runs: **{check_runs_total}**
|
|
- Combined status state: **{combined_state}**
|
|
- Completed suites (non-action_required): **{len(completed_suites)}**
|
|
- Base SHA: **`{current_base_sha}`**
|
|
|
|
## Previous state (from last run)
|
|
- Previous head SHA: `{prev_sha or 'unknown'}`
|
|
- Previous action_required suites: `{prev_action_required if prev_action_required is not None else 'unknown'}`
|
|
- Previous check runs: `{prev_check_runs if prev_check_runs is not None else 'unknown'}`
|
|
- Previous base SHA: `{prev_base_sha or ('unknown — check latest-reviewer-handoff.md for baseline')}`
|
|
|
|
## Detected transitions
|
|
- Approval transition (action_required → cleared): **{'YES' if approval_transition else 'no'}**
|
|
- CI started transition (0 check runs → >0): **{'YES' if ci_started_transition else 'no'}**
|
|
- Base branch drift (origin/main advanced since last refresh): **{'YES — RERUN BRANCH REFRESH' if base_branch_drift_transition else 'no'}**
|
|
|
|
## Exact next move
|
|
{"Rerun the branch-refresh script before re-triggering CI — origin/main has advanced since the last recorded base SHA. See latest-pr-branch-refresh.md for the refresh procedure." if base_branch_drift_transition else "Run `bash starter-kits/delegation-readiness-doctor/scripts/emit-pr-review-monitor.sh` and `bash starter-kits/delegation-readiness-doctor/scripts/emit-ci-result-interpreter.sh` to get the post-approval CI surface." if blocker_cleared or approval_transition else "Maintainer workflow approval is still the blocker. The maintainer unblock request is already posted, so do not repost it unless the blocker signature changes materially; wait for a detector-visible approval, review, or check-run start and then refresh the PR/CI packet immediately." if already_posted_nudge else "Maintainer workflow approval is still the blocker. Use `latest-workflow-approval-trigger.md` for the ready-to-post nudge."}
|
|
|
|
## Check run details
|
|
{chr(10).join(f"- {c['name']} — {c['status']} / {c.get('conclusion') or 'pending'}" for c in check_runs.get('check_runs', [])) or '- none yet'}
|
|
|
|
## Suite details
|
|
{chr(10).join(f"- Suite {s['id']} — {s.get('status')} / {s.get('conclusion') or 'pending'}" for s in check_suites.get('check_suites', [])) or '- none'}
|
|
|
|
---
|
|
|
|
*This artifact is the state-change detector for the fork-workflow approval blocker. It compares current GitHub Actions state against the previous run to surface transitions, so the automation system knows when the blocker has cleared without manual snapshot comparison.*
|
|
"""
|
|
report_path.write_text(report, encoding='utf-8')
|
|
shutil.copyfile(report_path, latest_path)
|
|
print(report_path)
|
|
print(verdict)
|
|
PY
|
|
|
|
chmod +x "$SCRIPT_DIR/emit-workflow-approval-state-change.sh"
|
|
printf 'Wrote report: %s\n' "$REPORT_PATH"
|
|
printf 'Latest report: %s\n' "$LATEST_PATH"
|