GRM-50: fix: workflow timing, timeouts, status polling, and release classification
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user