hermes-agent/starter-kits/delegation-readiness-doctor/scripts/emit-workflow-approval-state-change.sh

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"