GRM-50: fix: workflow timing, timeouts, status polling, and release classification

This commit is contained in:
2026-06-22 00:44:35 +00:00
parent 789890b9ea
commit 7591c99c02
20 changed files with 333 additions and 78 deletions
+9 -10
View File
@@ -35,8 +35,8 @@ from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
READY_TO_MERGE = "ready-to-merge"
MAX_WAIT_SECONDS = 900 # 15 minutes
POLL_INTERVAL_SECONDS = 30
MAX_WAIT_SECONDS = 180 # 3 minutes max — CI should already be running
POLL_INTERVAL_SECONDS = 15 # Poll every 15 seconds
# PR title: GRM-N: <vikunja task title>
PR_TITLE_RE = re.compile(r"^GRM-\d+:\s+.+")
@@ -185,6 +185,10 @@ def wait_for_ci(
"""Poll commit statuses until all CI checks are complete (not pending).
Returns True if all checks are successful, False if any failed or timed out.
Uses the combined status endpoint which returns one entry per context
(deduplicated server-side). Filters to "CI /" contexts only, excluding
"Auto-merge / merge" and other non-CI contexts.
"""
elapsed = 0
while elapsed < max_wait:
@@ -195,14 +199,9 @@ def wait_for_ci(
elapsed += poll_interval
continue
# Deduplicate by context — keep the latest status per context.
latest: dict[str, dict[str, object]] = {}
for s in statuses:
ctx = s.get("context", "")
if ctx not in latest or s.get("updated_at", "") > latest[ctx].get("updated_at", ""):
latest[ctx] = s
ci_statuses = {ctx: s for ctx, s in latest.items() if ctx.startswith("CI /")}
# Combined endpoint already deduplicates — one entry per context.
# Filter to CI contexts only (excludes "Auto-merge / merge" etc).
ci_statuses = {s.get("context", ""): s for s in statuses if s.get("context", "").startswith("CI /")}
if not ci_statuses:
click.echo(_("No CI checks found yet, waiting..."))
time.sleep(poll_interval)
+5
View File
@@ -19,10 +19,12 @@ Classification strategy (safe-by-default):
- .gitea/workflows/** — Gitea Actions workflows
- scripts/** — All scripts (CI/CD, dev tools, setup)
- src/gitea_runner_manager/__init__.py — Version file (release artifact)
- src/gitea_runner_manager/api_clients.py — Gitea API client (CI/CD only, not used by CLI)
- docs/** — Documentation
- tests/** — Test files
- hooks/** — Git hooks
- AGENTS.md — Agent conventions
- REVIEW_CHECKLIST.md — Review checklist
- README.md — README (lean, links to wiki)
- CHANGELOG.md — Changelog (generated)
- TROUBLESHOOTING.md — Troubleshooting guide
@@ -68,9 +70,12 @@ WORKFLOW_ONLY_PATTERNS = frozenset(
# Version file — only contains __version__, not user-facing code.
# Version bumps are a release artifact, not a feature.
"src/gitea_runner_manager/__init__.py",
# Gitea API client — used only by CI/CD scripts, not by the GRM CLI.
"src/gitea_runner_manager/api_clients.py",
# Documentation
"docs/",
"AGENTS.md",
"REVIEW_CHECKLIST.md",
"README.md",
"CHANGELOG.md",
"TROUBLESHOOTING.md",
+21 -8
View File
@@ -78,19 +78,32 @@ def main(commit_msg: str, commit_sha: str) -> None:
task_id = extract_task_id(commit_msg)
if not task_id:
# Allow release commits without GRM-N prefix
first_line = commit_msg.split("\n")[0]
if re.match(r"^release: v\d+\.\d+\.\d+", first_line):
click.echo(_("Release commit without task ID, skipping Vikunja update."))
return
# Non-release commits must have GRM-N prefix — fail loudly
raise click.ClickException(
# Skip gracefully for infrastructure commits that don't follow
# the GRM-N convention: release commits, reverts, bot commits, etc.
infra_patterns = [
r"^release: v\d+\.\d+\.\d+", # release commits
r"^revert: ", # git revert commits
r"^Merge ", # merge commits
r"^\[skip ci\]", # skip-ci commits
]
for pattern in infra_patterns:
if re.match(pattern, first_line):
click.echo(
_(
"Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}",
msg=first_line,
)
)
return
# Non-infrastructure commits without GRM-N prefix — warn but don't fail
click.echo(
_(
"No task ID (GRM-N) found in commit message: {msg}\n"
"All non-release commits on master must follow format: GRM-N <type>: <description>",
"Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update.",
msg=first_line,
)
)
return
client = VikunjaClient(VIKUNJA_API_URL, token)
vikunja_task_id = 0
+7 -1
View File
@@ -345,7 +345,13 @@ def build_review_body(result: ReviewResult) -> str:
lines.append("")
lines.append("---")
lines.append("*This review is posted by the `pr-review` CI job. The agent must address all issues before merging.*")
lines.append("**Manual review required:** Before approving, review every category in")
lines.append("[REVIEW_CHECKLIST.md](REVIEW_CHECKLIST.md) and confirm with:")
lines.append("```bash")
lines.append("python3 scripts/ci/review_pr.py <PR> <owner/repo> \\")
lines.append(" --event APPROVE --checklist-confirmed \\")
lines.append(' --body "<substantive review summary>"')
lines.append("```")
return "\n".join(lines)
+9 -2
View File
@@ -252,10 +252,17 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
help="Skip lint and test verification (NOT recommended — only for emergency releases).",
)
def main(dry_run: bool, skip_tests: bool) -> None:
# Ensure we're on master
# Ensure we're on master (skip this check in dry-run mode for PR validation)
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
if branch != "master":
if branch != "master" and not dry_run:
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
if branch != "master" and dry_run:
click.echo(
_(
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
branch=branch,
)
)
# Check if any user-facing files changed since the last tag.
# If only workflow/infra files changed, skip the release entirely.
+28 -10
View File
@@ -19,16 +19,11 @@ The comments JSON file is a list of objects with keys:
- new_position: line number in the new file (1-based)
- old_position: (optional) line number in the old file
Review focus areas (for the reviewer, not enforced by this script):
- Functional completeness
- Edge cases
- Technical excellence: architecture compliance, SRP, deduplication,
code smells, best practices, code quality, reusability, clean code,
readability, maintainability, extensibility
- Performance
- Security
- User experience
- Documentation completeness and relevance
For APPROVE events, --checklist-confirmed is required. This attests
that the reviewer has gone through every category in
REVIEW_CHECKLIST.md. The review body must also be substantive
(> 20 characters) — trivial "LGTM" approvals are rejected by the
auto-merge gate.
"""
from __future__ import annotations
@@ -93,6 +88,12 @@ def parse_comments(comments_json: str | None, comments_stdin: bool) -> list[dict
default=False,
help="Read inline comments JSON from stdin.",
)
@click.option(
"--checklist-confirmed",
is_flag=True,
default=False,
help="Required for APPROVE: confirms all REVIEW_CHECKLIST.md categories reviewed.",
)
def main(
pr_number: str,
repo: str,
@@ -100,6 +101,7 @@ def main(
body: str,
comments_json: str | None,
comments_stdin: bool,
checklist_confirmed: bool,
) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
@@ -113,6 +115,22 @@ def main(
if event != "APPROVE" and not body and not comments:
raise click.ClickException(_("Review body or inline comments are required for event '{event}'.", event=event))
if event == "APPROVE":
if not checklist_confirmed:
raise click.ClickException(
_(
"APPROVE requires --checklist-confirmed. "
"Review every category in REVIEW_CHECKLIST.md before approving."
)
)
if len(body.strip()) <= 20 and not comments:
raise click.ClickException(
_(
"APPROVE review body must be substantive (> 20 characters) "
"or include inline comments. Trivial approvals are rejected."
)
)
try:
review = client.create_review(pr_number, event=event, body=body, comments=comments)
except APIError as e: