diff --git a/deliver/SKILL.md.tmpl b/deliver/SKILL.md.tmpl new file mode 100644 index 0000000..e900880 --- /dev/null +++ b/deliver/SKILL.md.tmpl @@ -0,0 +1,244 @@ +--- +name: deliver +version: 1.0.0 +description: | + Run the delivery pipeline for a branch — CI, quality gates, merge. Automates the full + path from "PR created" to "merged to main". Polls CI, runs pluggable quality gates + (verify-app, code-simplifier, custom agents), auto-merges when all blocking gates pass. + Use when asked to "deliver", "land this", "merge when ready", or "ship and merge". +allowed-tools: + - Bash + - Read + - Write + - Edit + - Glob + - Grep + - AskUserQuestion +--- + +{{PREAMBLE}} + +# /deliver: Automated Delivery Pipeline + +You are running the `/deliver` workflow. This automates the full delivery pipeline: validate branch, create/find PR, poll CI, run quality gates, and merge when all blocking gates pass. + +**This is a non-interactive workflow by default.** Do NOT ask for confirmation unless a blocking gate fails and needs user intervention. The user said `/deliver` which means DO IT. + +--- + +## Setup + +**Parse the user's request for these parameters:** + +| Parameter | Default | Override example | +|-----------|---------|-----------------| +| Branch | current branch | `deliver feat/my-feature` | +| Base | auto-detect (main or master) | `--base develop` | +| No merge | false | `--no-merge` (approve but don't merge) | +| Resume | n/a | `--resume` (resume from saved state) | +| Status | n/a | `--status` (show current pipeline state) | + +--- + +## First-Time Setup + +On first run, check if `~/.gstack/deliver/` exists. If not, bootstrap it: + +```bash +DELIVER_DIR="$HOME/.gstack/deliver" +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ ! -d "$DELIVER_DIR" ]]; then + mkdir -p "$DELIVER_DIR/agents" "$DELIVER_DIR/state" + # Copy example config as starting point + cp "$SKILL_DIR/gates.yaml.example" "$DELIVER_DIR/gates.yaml" + # Copy bundled agents as defaults + cp "$SKILL_DIR/agents/"*.md "$DELIVER_DIR/agents/" + echo "Initialized ~/.gstack/deliver/ with default config and agents." + echo "Edit ~/.gstack/deliver/gates.yaml to customize quality gates." +fi +``` + +--- + +## Step 1: Determine Branch and Mode + +```bash +BRANCH=$(git branch --show-current) +``` + +**If `--status` was passed:** Source the pipeline scripts and run `pipeline_status `. Print the state and exit. + +**If `--resume` was passed:** Source the pipeline scripts and run `pipeline_resume `. The pipeline will pick up from its last saved state. + +**If no branch specified and on main/master:** STOP. Tell the user: "You're on main. Deliver from a feature branch." + +**If no branch specified:** Use the current branch. + +--- + +## Step 2: Source Pipeline Scripts + +The delivery pipeline is implemented as composable bash scripts. Source them from the skill directory: + +```bash +SKILL_DIR="" +source "$SKILL_DIR/scripts/delivery-state.sh" +source "$SKILL_DIR/scripts/pr-manager.sh" +source "$SKILL_DIR/scripts/ci-monitor.sh" +source "$SKILL_DIR/scripts/gate-runner.sh" +``` + +--- + +## Step 3: Run the Pipeline + +Execute the end-to-end pipeline via `run.sh`: + +```bash +"$SKILL_DIR/scripts/run.sh" "$BRANCH" --base "$BASE" +``` + +Or with options: + +```bash +# Approve but don't merge +"$SKILL_DIR/scripts/run.sh" "$BRANCH" --no-merge + +# Resume a blocked pipeline +"$SKILL_DIR/scripts/run.sh" resume "$TASK_ID" + +# Check status +"$SKILL_DIR/scripts/run.sh" status "$TASK_ID" +``` + +--- + +## Step 4: Report Progress + +The pipeline emits structured output. Report each phase to the user: + +| Phase | What to report | +|-------|---------------| +| `PR_CREATING` | "Creating/finding PR for branch..." | +| `CI_RUNNING` | "PR #N created. Polling CI..." (with elapsed time) | +| `REVIEWING` | "CI passed. Running quality gates..." | +| `APPROVED` | "All blocking gates passed." | +| `MERGING` | "Merging PR #N..." | +| `MERGED` | "PR #N merged to {base}. Delivery complete." | +| `BLOCKED` | "Pipeline blocked: {reason}" | + +**On BLOCKED:** Show the specific failure (CI failure, gate failure, merge conflict) and suggest next steps. + +--- + +## Step 5: Handle Failures + +### CI Failure +``` +CI failed for PR #N. +Run `gh pr checks N` to see which checks failed. +Fix the issues, push, then run `/deliver --resume` to continue. +``` + +### Quality Gate Failure (blocking) +``` +Blocking gate "{gate-name}" failed for PR #N. +Review the gate output above and address the issues. +Then run `/deliver --resume` to re-run gates. +``` + +### Quality Gate Failure (non-blocking) +Non-blocking gate failures are logged but do NOT prevent merge. Report them as warnings: +``` +Warning: Non-blocking gate "{gate-name}" failed. See output above for suggestions. +``` + +### Merge Failure +``` +Merge failed for PR #N. +This usually means a merge conflict or branch protection rule. +Run `gh pr view N` for details. +``` + +--- + +## Pipeline State Machine + +The delivery tracks each branch through these states: + +``` +WORKING -> PR_CREATING -> CI_RUNNING -> REVIEWING -> APPROVED -> MERGING -> MERGED + | | + v v + BLOCKED <------+ + | + v + (resume from WORKING, CI_RUNNING, or REVIEWING) +``` + +State is persisted at `~/.gstack/deliver/state/deliveries.json`, enabling resume across sessions. + +--- + +## Quality Gates + +Gates are configured in `~/.gstack/deliver/gates.yaml`. Each gate specifies: + +- **enabled**: Whether the gate is active +- **blocking**: Whether failure blocks the merge +- **trigger**: When to run (always, file patterns, line count threshold) + +### Bundled Gates + +| Gate | Blocking | Trigger | Purpose | +|------|----------|---------|---------| +| `verify-app` | Yes | always | Type check, lint, test, build | +| `code-simplifier` | No | 50+ lines changed | Readability suggestions | + +### Custom Gates + +Add your own gates by: +1. Creating an agent `.md` file in `~/.gstack/deliver/agents/` +2. Adding a gate entry in `~/.gstack/deliver/gates.yaml` + +Agent `.md` files use YAML front matter + markdown body with a `## Review Mandate` section. + +--- + +## Configuration + +Edit `~/.gstack/deliver/gates.yaml` to customize: + +```yaml +gates: + verify-app: + enabled: true + blocking: true + trigger: always + + code-simplifier: + enabled: true + blocking: false + trigger: + min_lines_changed: 50 + + # Add custom gates here + +settings: + ci_poll_interval_seconds: 30 + ci_timeout_minutes: 30 + merge_method: squash # squash | merge | rebase + delete_branch_on_merge: true + auto_merge: true +``` + +--- + +## Important Rules + +- **Never deliver main/master.** The pipeline rejects attempts to deliver the default branch. +- **Never force-push.** The pipeline uses regular `git push` only. +- **Blocking gates must pass.** Non-blocking gates are advisory only. +- **State is resumable.** If the pipeline is interrupted, run `/deliver --resume` to continue. +- **The goal is: user says `/deliver`, next thing they see is "PR #N merged."** diff --git a/deliver/agents/code-simplifier.md b/deliver/agents/code-simplifier.md new file mode 100644 index 0000000..481658a --- /dev/null +++ b/deliver/agents/code-simplifier.md @@ -0,0 +1,115 @@ +--- +name: code-simplifier +description: Simplify and clean up code after changes — remove complexity, improve readability +model: claude-sonnet-4-5-20250929 +trigger: + min_lines_changed: 50 +blocking: false +gate_output_pattern: "RESULT:.*(PASS|FAIL)" +--- + +# Code Simplifier Agent + +You are a code simplification specialist. Your job is to take working code and make it simpler, cleaner, and more maintainable WITHOUT changing its behavior. + +## Your Principles + +1. **Less is more**: Remove unnecessary code, comments, and complexity +2. **Clarity over cleverness**: Prefer obvious solutions over clever ones +3. **Consistent patterns**: Use the same patterns throughout +4. **No behavioral changes**: The code must work exactly the same + +## What You Do + +When given code to simplify: + +### 1. Remove Cruft +- Dead code and unused imports +- Redundant comments that state the obvious +- Unnecessary type annotations (where inference works) +- Console.logs and debug statements +- TODO comments that are done + +### 2. Simplify Logic +- Flatten deeply nested conditionals +- Replace complex conditionals with early returns +- Combine related operations +- Use built-in methods instead of manual loops +- Replace verbose patterns with idiomatic ones + +### 3. Improve Naming +- Make variable names self-documenting +- Use consistent naming conventions +- Remove redundant prefixes/suffixes + +### 4. Reduce Duplication +- Extract repeated code into functions +- Use constants for magic values +- Apply DRY without over-abstracting + +## What You Don't Do + +- Add new features +- Change behavior (even if you think it's a bug) +- Add abstractions "for the future" +- Optimize for performance (unless obvious wins) +- Refactor architecture + +## Output Format + +For each file you simplify: + +``` +## [filename] + +### Changes: +- [change 1] +- [change 2] + +### Before (key section): +[code snippet] + +### After: +[simplified code] + +### Lines removed: X | Lines added: Y | Net: -Z +``` + +## Example Simplifications + +### Before: +```javascript +// Check if user is authenticated +if (user !== null && user !== undefined) { + if (user.isAuthenticated === true) { + // User is authenticated, proceed + return true; + } else { + return false; + } +} else { + return false; +} +``` + +### After: +```javascript +return user?.isAuthenticated ?? false; +``` + +## Review Mandate + +When invoked as a quality gate, focus on: + +1. **Unnecessary complexity**: Deeply nested conditionals, verbose patterns that have simpler equivalents. +2. **Dead code**: Unused imports, unreachable branches, commented-out code. +3. **Naming clarity**: Variables/functions with unclear or misleading names. +4. **Duplication**: Repeated code blocks that should be extracted. +5. **Consistency**: Mixed patterns within the same file or module. + +Do NOT suggest behavioral changes, performance optimizations, or architectural refactors. + +End your review with exactly one of: +- `RESULT: PASS` — Code is clean and readable +- `RESULT: FAIL` — Significant complexity issues found +- `RESULT: CONDITIONAL PASS` — Minor simplification suggestions diff --git a/deliver/agents/verify-app.md b/deliver/agents/verify-app.md new file mode 100644 index 0000000..682902d --- /dev/null +++ b/deliver/agents/verify-app.md @@ -0,0 +1,124 @@ +--- +name: verify-app +description: Comprehensive verification of changes before merge — types, lint, test, build, and manual checks +model: claude-sonnet-4-5-20250929 +trigger: always +blocking: true +gate_output_pattern: "RESULT:.*(PASS|FAIL)" +--- + +# Verify App Agent + +You are a QA specialist. Your job is to verify that changes work correctly before they are merged. + +## Verification Checklist + +Run through this checklist for every verification. + +### 1. Static Analysis + +Check the project's CLAUDE.md or README for the correct commands. Common patterns: + +```bash +# Type checking (try in order) +npm run type-check || npm run typecheck || pnpm typecheck || yarn typecheck || npx tsc --noEmit + +# Linting +npm run lint || pnpm lint || yarn lint + +# Format check (if configured) +npm run format:check || npx prettier --check . +``` + +### 2. Test Suite + +```bash +# Unit tests +npm test || pnpm test || yarn test + +# Integration tests (if they exist) +npm run test:integration 2>/dev/null || true + +# E2E tests (if quick and available) +npm run test:e2e 2>/dev/null || true +``` + +### 3. Build Verification + +```bash +# Ensure the project builds cleanly +npm run build || pnpm build || yarn build +``` + +### 4. Manual Checks + +For the specific changes in this PR: + +- [ ] Does the happy path work? +- [ ] Do edge cases work? +- [ ] Are error states handled? +- [ ] Is loading state shown? +- [ ] Does it work on mobile? (if UI changes) +- [ ] Is it accessible? (keyboard nav, screen reader) + +### 5. Security Scan + +- [ ] No secrets in code +- [ ] No unsafe eval() or innerHTML +- [ ] User input is sanitized +- [ ] Auth checks in place + +## Output Format + +``` +====================================================== + VERIFICATION REPORT +====================================================== + Branch: feature/xxx + Changes: X files, +Y/-Z lines +------------------------------------------------------ + Types: PASS + Lint: PASS + Tests: PASS (47 passed, 0 failed) + Build: PASS + Manual: 1 issue found +------------------------------------------------------ + RESULT: CONDITIONAL PASS +====================================================== + +Issues to address: +1. [issue description and how to fix] + +Recommendations: +- [any suggestions for improvement] +``` + +## Failure Handling + +If any check fails: +1. Stop and report the failure +2. Provide the exact error message +3. Suggest how to fix it +4. Do NOT attempt to fix it yourself (that is the worker's job) + +## Review Mandate + +When invoked as a quality gate, focus on: + +1. **Build integrity**: Does the project build without errors? +2. **Type safety**: Does type-checking pass? +3. **Lint compliance**: No lint errors in changed files. +4. **Test suite**: All tests pass, no regressions. +5. **Security basics**: No secrets, no unsafe patterns in changed code. + +End your review with exactly one of: +- `RESULT: PASS` — All verification checks pass +- `RESULT: FAIL` — Build, type, lint, or test failures +- `RESULT: CONDITIONAL PASS` — Minor issues that don't block merge + +## When Called + +This agent should be called: +- Before any merge +- Before any PR delivery +- When a worker reports "done" diff --git a/deliver/gates.yaml.example b/deliver/gates.yaml.example new file mode 100644 index 0000000..0352f4b --- /dev/null +++ b/deliver/gates.yaml.example @@ -0,0 +1,52 @@ +# Quality gate configuration for the /deliver pipeline +# Copy this to ~/.gstack/deliver/gates.yaml and customize for your project. +# +# Each gate references an agent .md file in ~/.gstack/deliver/agents/ (or the +# skill's bundled agents/ directory as fallback). +# +# Trigger types: +# always — run on every PR +# files_match: [patterns] — run only when matching files change +# min_lines_changed: N — run only when total lines changed >= N + +gates: + verify-app: + enabled: true + blocking: true + trigger: always + + code-simplifier: + enabled: true + blocking: false + trigger: + min_lines_changed: 50 + + # Example: infrastructure review gate (disabled by default) + # infra-review: + # enabled: false + # blocking: false + # trigger: + # files_match: + # - ".github/**" + # - "Dockerfile*" + # - "docker-compose*" + # - ".env*" + # - "infrastructure/**" + + # Example: security audit gate (disabled by default) + # security-audit: + # enabled: false + # blocking: true + # trigger: + # files_match: + # - "auth/**" + # - "middleware/**" + # - "**/security*" + # - "**/crypto*" + +settings: + ci_poll_interval_seconds: 30 + ci_timeout_minutes: 30 + merge_method: squash # squash | merge | rebase + delete_branch_on_merge: true + auto_merge: true diff --git a/deliver/scripts/agent-registry.sh b/deliver/scripts/agent-registry.sh new file mode 100755 index 0000000..9fa1495 --- /dev/null +++ b/deliver/scripts/agent-registry.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# Agent registry for the delivery pipeline +# Loads agent definitions from .md files with YAML front matter. +# +# Agent .md format: +# --- +# name: agent-name +# description: Short description +# model: claude-sonnet-4-5-20250929 +# trigger: always | { files_match: [...] } | { min_lines_changed: N } +# blocking: true | false +# gate_output_pattern: "RESULT:.*(PASS|FAIL)" +# --- +# # Agent Title +# Markdown body... +# ## Review Mandate +# Review-specific instructions... +# +# Usage: +# agent-registry.sh list +# agent-registry.sh metadata +# agent-registry.sh prompt +# agent-registry.sh review-mandate + +set -eo pipefail + +# Agent directories to search (in priority order) +_agent_dirs() { + local dirs=() + + # Allow override via env var (for testing) + if [[ -n "${GSTACK_DELIVER_AGENTS_DIR:-}" ]]; then + dirs+=("$GSTACK_DELIVER_AGENTS_DIR") + printf '%s\n' "${dirs[@]}" + return + fi + + # User-configured agents (highest priority — user customizations) + if [[ -d "$HOME/.gstack/deliver/agents" ]]; then + dirs+=("$HOME/.gstack/deliver/agents") + fi + + # Skill-bundled agents (fallback defaults) + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if [[ -d "$script_dir/../agents" ]]; then + dirs+=("$script_dir/../agents") + fi + + printf '%s\n' "${dirs[@]}" +} + +# Find agent file by name +_find_agent() { + local name="$1" + local dir + while IFS= read -r dir; do + if [[ -f "$dir/${name}.md" ]]; then + echo "$dir/${name}.md" + return 0 + fi + done < <(_agent_dirs) + + echo "Error: Agent '$name' not found" >&2 + return 1 +} + +# Extract YAML front matter from a .md file +# Returns the content between the first and second '---' lines +_extract_front_matter() { + local file="$1" + sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$file" +} + +# Extract Markdown body (everything after the second '---') +_extract_body() { + local file="$1" + sed -n '/^---$/,/^---$/d; p' "$file" | sed '/./,$!d' +} + +# Extract a specific section from Markdown body +_extract_section() { + local file="$1" section="$2" + local in_section=false + + while IFS= read -r line; do + if [[ "$line" =~ ^##[[:space:]]+"$section" ]] || [[ "$line" == "## $section" ]]; then + in_section=true + continue + fi + if [[ "$in_section" == true ]]; then + # Stop at next section heading + if [[ "$line" =~ ^##[[:space:]] ]]; then + break + fi + echo "$line" + fi + done < <(_extract_body "$file") +} + +# List all agents with their metadata +agent_list() { + local dir + while IFS= read -r dir; do + for file in "$dir"/*.md; do + [[ -f "$file" ]] || continue + local name + name=$(basename "$file" .md) + local desc + desc=$(awk '/^---$/{n++; next} n==1 && /^description:/{sub(/^description:[[:space:]]*/, ""); print}' "$file") + printf "%-20s %s\n" "$name" "$desc" + done + done < <(_agent_dirs) +} + +# Get agent metadata as key-value pairs +agent_get_metadata() { + local name="$1" + if [[ -z "$name" ]]; then + echo "Usage: agent_get_metadata " >&2 + return 1 + fi + + local file + file=$(_find_agent "$name") || return 1 + _extract_front_matter "$file" +} + +# Get agent's full Markdown prompt (body) +agent_get_prompt() { + local name="$1" + if [[ -z "$name" ]]; then + echo "Usage: agent_get_prompt " >&2 + return 1 + fi + + local file + file=$(_find_agent "$name") || return 1 + _extract_body "$file" +} + +# Get agent's review mandate section +agent_get_review_mandate() { + local name="$1" + if [[ -z "$name" ]]; then + echo "Usage: agent_get_review_mandate " >&2 + return 1 + fi + + local file + file=$(_find_agent "$name") || return 1 + local mandate + mandate=$(_extract_section "$file" "Review Mandate") + + if [[ -z "$mandate" ]]; then + echo "Warning: No '## Review Mandate' section found in $name" >&2 + # Fall back to full prompt + _extract_body "$file" + else + echo "$mandate" + fi +} + +# CLI dispatch +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + case "${1:-}" in + list) shift; agent_list "$@" ;; + metadata) shift; agent_get_metadata "$@" ;; + prompt) shift; agent_get_prompt "$@" ;; + review-mandate) shift; agent_get_review_mandate "$@" ;; + *) + echo "Usage: agent-registry.sh {list|metadata|prompt|review-mandate} [args...]" >&2 + exit 1 + ;; + esac +fi diff --git a/deliver/scripts/ci-monitor.sh b/deliver/scripts/ci-monitor.sh new file mode 100755 index 0000000..6112370 --- /dev/null +++ b/deliver/scripts/ci-monitor.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# CI monitoring for the delivery pipeline +# Polls GitHub Actions CI status until completion or timeout. +# +# Usage: +# ci-monitor.sh poll [--interval ] [--timeout ] +# ci-monitor.sh status + +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Default settings (overridden by gates.yaml if available) +DEFAULT_POLL_INTERVAL=30 +DEFAULT_TIMEOUT=1800 # 30 minutes + +# Load settings from gates.yaml if available +_load_settings() { + local gates_file="${GATES_CONFIG:-$HOME/.gstack/deliver/gates.yaml}" + if [[ ! -f "$gates_file" ]]; then + gates_file="$SCRIPT_DIR/../gates.yaml.example" + fi + + if [[ -f "$gates_file" ]] && command -v grep &>/dev/null; then + local interval timeout_min + interval=$(grep 'ci_poll_interval_seconds:' "$gates_file" 2>/dev/null | awk '{print $2}') + timeout_min=$(grep 'ci_timeout_minutes:' "$gates_file" 2>/dev/null | awk '{print $2}') + + if [[ -n "$interval" ]]; then + DEFAULT_POLL_INTERVAL="$interval" + fi + if [[ -n "$timeout_min" ]]; then + DEFAULT_TIMEOUT=$((timeout_min * 60)) + fi + fi +} + +# One-shot CI status check +ci_status() { + local pr_number="$1" + if [[ -z "$pr_number" ]]; then + echo "Usage: ci_status " >&2 + return 1 + fi + + if ! command -v gh &>/dev/null; then + echo "Error: GitHub CLI (gh) is required." >&2 + return 1 + fi + + local checks + checks=$(gh pr view "$pr_number" --json statusCheckRollup -q '.statusCheckRollup' 2>/dev/null) + + if [[ -z "$checks" || "$checks" == "[]" || "$checks" == "null" ]]; then + echo "unknown" + return 0 + fi + + # Check for any failures + if echo "$checks" | jq -e 'any(.[]; .conclusion == "FAILURE" or .conclusion == "ERROR")' &>/dev/null; then + echo "failed" + return 0 + fi + + # Check if all completed successfully + if echo "$checks" | jq -e 'all(.[]; .conclusion == "SUCCESS" or .conclusion == "NEUTRAL" or .conclusion == "SKIPPED")' &>/dev/null; then + echo "passed" + return 0 + fi + + echo "pending" +} + +# Poll until CI completes or times out +ci_poll() { + local pr_number="" interval="" timeout="" + + if [[ $# -gt 0 && ! "$1" =~ ^-- ]]; then + pr_number="$1"; shift + fi + + while [[ $# -gt 0 ]]; do + case "$1" in + --interval) interval="$2"; shift 2 ;; + --timeout) timeout="$2"; shift 2 ;; + *) shift ;; + esac + done + + if [[ -z "$pr_number" ]]; then + echo "Usage: ci_poll [--interval ] [--timeout ]" >&2 + return 1 + fi + + _load_settings + interval="${interval:-$DEFAULT_POLL_INTERVAL}" + timeout="${timeout:-$DEFAULT_TIMEOUT}" + + local elapsed=0 + echo "Polling CI for PR #${pr_number} (interval: ${interval}s, timeout: ${timeout}s)..." >&2 + + while [[ $elapsed -lt $timeout ]]; do + local status + status=$(ci_status "$pr_number") + + case "$status" in + passed) + echo "passed" + return 0 + ;; + failed) + echo "failed" + return 1 + ;; + unknown|pending) + echo " [${elapsed}s] CI status: ${status}" >&2 + sleep "$interval" + elapsed=$((elapsed + interval)) + ;; + esac + done + + echo " CI timed out after ${timeout}s" >&2 + echo "timeout" + return 2 +} + +# CLI dispatch +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + case "${1:-}" in + status) shift; ci_status "$@" ;; + poll) shift; ci_poll "$@" ;; + *) + echo "Usage: ci-monitor.sh {status|poll} [args...]" >&2 + exit 1 + ;; + esac +fi diff --git a/deliver/scripts/delivery-state.sh b/deliver/scripts/delivery-state.sh new file mode 100755 index 0000000..f2ecc5c --- /dev/null +++ b/deliver/scripts/delivery-state.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# Delivery state machine for the pipeline +# Tracks each PR/task through: WORKING -> PR_CREATING -> CI_RUNNING -> REVIEWING -> APPROVED -> MERGING -> MERGED | BLOCKED +# +# Usage: +# delivery-state.sh init +# delivery-state.sh transition +# delivery-state.sh get +# delivery-state.sh list [--state ] +# delivery-state.sh cleanup + +set -eo pipefail + +STATE_DIR="${GSTACK_DELIVER_STATE_DIR:-$HOME/.gstack/deliver/state}" +STATE_FILE="$STATE_DIR/deliveries.json" + +# Ensure state file exists +_init_state_file() { + if [[ ! -d "$STATE_DIR" ]]; then + mkdir -p "$STATE_DIR" + fi + if [[ ! -f "$STATE_FILE" ]]; then + echo '{"deliveries":{}}' > "$STATE_FILE" + fi +} + +# Validate state name +_valid_state() { + local state="$1" + case "$state" in + WORKING|PR_CREATING|CI_RUNNING|REVIEWING|APPROVED|MERGING|MERGED|BLOCKED) return 0 ;; + *) return 1 ;; + esac +} + +# Validate state transition +_valid_transition() { + local from="$1" to="$2" + case "${from}->${to}" in + "WORKING->PR_CREATING") return 0 ;; + "PR_CREATING->CI_RUNNING") return 0 ;; + "CI_RUNNING->REVIEWING") return 0 ;; + "CI_RUNNING->BLOCKED") return 0 ;; + "REVIEWING->APPROVED") return 0 ;; + "REVIEWING->BLOCKED") return 0 ;; + "APPROVED->MERGING") return 0 ;; + "MERGING->MERGED") return 0 ;; + "BLOCKED->WORKING") return 0 ;; + "BLOCKED->CI_RUNNING") return 0 ;; + "BLOCKED->REVIEWING") return 0 ;; + *) return 1 ;; + esac +} + +# Initialize a new delivery entry +delivery_init() { + local task_id="$1" branch="$2" + if [[ -z "$task_id" || -z "$branch" ]]; then + echo "Usage: delivery_init " >&2 + return 1 + fi + + _init_state_file + + local now + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + local entry + entry=$(jq -n \ + --arg tid "$task_id" \ + --arg br "$branch" \ + --arg now "$now" \ + '{ + taskId: $tid, + branch: $br, + prNumber: null, + state: "WORKING", + gates: {}, + createdAt: $now, + updatedAt: $now + }') + + jq --arg tid "$task_id" --argjson entry "$entry" \ + '.deliveries[$tid] = $entry' "$STATE_FILE" > "${STATE_FILE}.tmp" \ + && mv "${STATE_FILE}.tmp" "$STATE_FILE" + + echo "$entry" +} + +# Transition a delivery to a new state +delivery_transition() { + local task_id="$1" new_state="$2" + if [[ -z "$task_id" || -z "$new_state" ]]; then + echo "Usage: delivery_transition " >&2 + return 1 + fi + + _init_state_file + + if ! _valid_state "$new_state"; then + echo "Error: Invalid state '$new_state'" >&2 + return 1 + fi + + local current_state + current_state=$(jq -r --arg tid "$task_id" '.deliveries[$tid].state // empty' "$STATE_FILE") + if [[ -z "$current_state" ]]; then + echo "Error: No delivery found for task '$task_id'" >&2 + return 1 + fi + + if ! _valid_transition "$current_state" "$new_state"; then + echo "Error: Invalid transition ${current_state} -> ${new_state}" >&2 + return 1 + fi + + local now + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + jq --arg tid "$task_id" --arg state "$new_state" --arg now "$now" \ + '.deliveries[$tid].state = $state | .deliveries[$tid].updatedAt = $now' \ + "$STATE_FILE" > "${STATE_FILE}.tmp" \ + && mv "${STATE_FILE}.tmp" "$STATE_FILE" + + echo "Transitioned $task_id: $current_state -> $new_state" +} + +# Get a delivery entry as JSON +delivery_get() { + local task_id="$1" + if [[ -z "$task_id" ]]; then + echo "Usage: delivery_get " >&2 + return 1 + fi + + _init_state_file + + local entry + entry=$(jq --arg tid "$task_id" '.deliveries[$tid] // empty' "$STATE_FILE") + if [[ -z "$entry" || "$entry" == "null" ]]; then + echo "Error: No delivery found for task '$task_id'" >&2 + return 1 + fi + + echo "$entry" +} + +# List deliveries, optionally filtered by state +delivery_list() { + _init_state_file + + local filter_state="" + while [[ $# -gt 0 ]]; do + case "$1" in + --state) filter_state="$2"; shift 2 ;; + *) shift ;; + esac + done + + if [[ -n "$filter_state" ]]; then + jq --arg state "$filter_state" \ + '[.deliveries | to_entries[] | select(.value.state == $state) | .value]' \ + "$STATE_FILE" + else + jq '[.deliveries | to_entries[] | .value]' "$STATE_FILE" + fi +} + +# Remove a completed delivery +delivery_cleanup() { + local task_id="$1" + if [[ -z "$task_id" ]]; then + echo "Usage: delivery_cleanup " >&2 + return 1 + fi + + _init_state_file + + jq --arg tid "$task_id" 'del(.deliveries[$tid])' \ + "$STATE_FILE" > "${STATE_FILE}.tmp" \ + && mv "${STATE_FILE}.tmp" "$STATE_FILE" + + echo "Cleaned up delivery: $task_id" +} + +# Set PR number on a delivery +delivery_set_pr() { + local task_id="$1" pr_number="$2" + if [[ -z "$task_id" || -z "$pr_number" ]]; then + echo "Usage: delivery_set_pr " >&2 + return 1 + fi + + _init_state_file + + local now + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + jq --arg tid "$task_id" --argjson pr "$pr_number" --arg now "$now" \ + '.deliveries[$tid].prNumber = $pr | .deliveries[$tid].updatedAt = $now' \ + "$STATE_FILE" > "${STATE_FILE}.tmp" \ + && mv "${STATE_FILE}.tmp" "$STATE_FILE" +} + +# Set gate result for a delivery +delivery_set_gate() { + local task_id="$1" gate_name="$2" result="$3" + if [[ -z "$task_id" || -z "$gate_name" || -z "$result" ]]; then + echo "Usage: delivery_set_gate " >&2 + return 1 + fi + + _init_state_file + + local now + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + jq --arg tid "$task_id" --arg gate "$gate_name" --arg res "$result" --arg now "$now" \ + '.deliveries[$tid].gates[$gate] = $res | .deliveries[$tid].updatedAt = $now' \ + "$STATE_FILE" > "${STATE_FILE}.tmp" \ + && mv "${STATE_FILE}.tmp" "$STATE_FILE" +} + +# CLI dispatch +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + case "${1:-}" in + init) shift; delivery_init "$@" ;; + transition) shift; delivery_transition "$@" ;; + get) shift; delivery_get "$@" ;; + list) shift; delivery_list "$@" ;; + cleanup) shift; delivery_cleanup "$@" ;; + set-pr) shift; delivery_set_pr "$@" ;; + set-gate) shift; delivery_set_gate "$@" ;; + *) + echo "Usage: delivery-state.sh {init|transition|get|list|cleanup|set-pr|set-gate} [args...]" >&2 + exit 1 + ;; + esac +fi diff --git a/deliver/scripts/gate-runner.sh b/deliver/scripts/gate-runner.sh new file mode 100755 index 0000000..1e84685 --- /dev/null +++ b/deliver/scripts/gate-runner.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env bash +# Quality gate orchestrator for the delivery pipeline +# Reads gates.yaml, determines which agents to invoke per PR, runs them, collects results. +# +# Usage: +# gate-runner.sh run-all +# gate-runner.sh run +# gate-runner.sh status +# gate-runner.sh needs + +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Load gates config +_gates_config() { + local gates_file="${GATES_CONFIG:-$HOME/.gstack/deliver/gates.yaml}" + if [[ ! -f "$gates_file" ]]; then + gates_file="$SCRIPT_DIR/../gates.yaml.example" + fi + if [[ ! -f "$gates_file" ]]; then + echo "Error: gates.yaml not found. Run /deliver once to initialize, or create ~/.gstack/deliver/gates.yaml" >&2 + return 1 + fi + echo "$gates_file" +} + +# Parse a YAML value from gates.yaml (simple key extraction) +_yaml_value() { + local file="$1" key="$2" + grep "^[[:space:]]*${key}:" "$file" 2>/dev/null | head -1 | sed "s/^[[:space:]]*${key}:[[:space:]]*//" +} + +# Get gate config for a specific agent +_gate_config() { + local agent_name="$1" + local gates_file + gates_file=$(_gates_config) || return 1 + + # Extract the block for this gate + local block + # Use sed to extract the agent's config block, then remove last line (next section header) + block=$(sed -n "/^ ${agent_name}:/,/^ [a-z]/p" "$gates_file" | sed '$d') + + if [[ -z "$block" ]]; then + # Last entry: extract from agent to next top-level key (non-indented) or EOF + block=$(sed -n "/^ ${agent_name}:/,/^[a-z]/p" "$gates_file" | sed '$d') + fi + + if [[ -z "$block" ]]; then + # Truly the last entry in file with nothing after it + block=$(sed -n "/^ ${agent_name}:/,\$p" "$gates_file") + fi + + if [[ -z "$block" ]]; then + echo "Error: No gate config found for '$agent_name'" >&2 + return 1 + fi + + echo "$block" +} + +# Check if a gate is enabled +_gate_enabled() { + local agent_name="$1" + local block + block=$(_gate_config "$agent_name" 2>/dev/null) || return 1 + local enabled + enabled=$(echo "$block" | grep 'enabled:' | awk '{print $2}') + [[ "$enabled" == "true" ]] +} + +# Check if a gate is blocking +_gate_blocking() { + local agent_name="$1" + local block + block=$(_gate_config "$agent_name" 2>/dev/null) || return 1 + local blocking + blocking=$(echo "$block" | grep 'blocking:' | awk '{print $2}') + [[ "$blocking" == "true" ]] +} + +# Get list of all gate names +_gate_names() { + local gates_file + gates_file=$(_gates_config) || return 1 + # Gate names are indented with 2 spaces under "gates:" + sed -n '/^gates:/,/^[a-z]/p' "$gates_file" | grep '^ [a-z]' | sed 's/:[[:space:]]*$//' | sed 's/^ //' +} + +# Check if a gate should run for this PR based on trigger conditions +needs_gate() { + local agent_name="$1" pr_number="$2" + + if [[ -z "$agent_name" || -z "$pr_number" ]]; then + echo "Usage: needs_gate " >&2 + return 1 + fi + + # Check if gate is enabled + if ! _gate_enabled "$agent_name"; then + return 1 + fi + + local block + block=$(_gate_config "$agent_name") || return 1 + + # Check trigger type + if echo "$block" | grep -q 'trigger: always'; then + return 0 + fi + + # File pattern trigger + local files_match + files_match=$(echo "$block" | sed -n '/files_match:/,/^[[:space:]]*[a-z]/p' | grep '^\s*-' | sed 's/^[[:space:]]*-[[:space:]]*//' | tr -d '"') + + if [[ -n "$files_match" ]]; then + # Get changed files for this PR + local changed_files + changed_files=$(gh pr diff "$pr_number" --name-only 2>/dev/null) || return 1 + + while IFS= read -r pattern; do + [[ -z "$pattern" ]] && continue + # Convert glob to grep regex (simple: ** -> .*, * -> [^/]*) + local regex + regex=$(echo "$pattern" | sed 's/\*\*/.*/g; s/\*/[^\/]*/g') + if echo "$changed_files" | grep -qE "$regex"; then + return 0 + fi + done <<< "$files_match" + return 1 + fi + + # Line count trigger + local min_lines + min_lines=$(echo "$block" | grep 'min_lines_changed:' | awk '{print $2}') + + if [[ -n "$min_lines" ]]; then + local additions deletions total + additions=$(gh pr view "$pr_number" --json additions -q '.additions' 2>/dev/null || echo 0) + deletions=$(gh pr view "$pr_number" --json deletions -q '.deletions' 2>/dev/null || echo 0) + total=$((additions + deletions)) + [[ $total -ge $min_lines ]] + return $? + fi + + # Default: run the gate + return 0 +} + +# Run a single gate +run_gate() { + local agent_name="$1" pr_number="$2" branch="$3" + + if [[ -z "$agent_name" || -z "$pr_number" || -z "$branch" ]]; then + echo "Usage: run_gate " >&2 + return 1 + fi + + echo "Running gate: $agent_name for PR #$pr_number..." >&2 + + # Get agent review mandate + local mandate + mandate=$("$SCRIPT_DIR/agent-registry.sh" review-mandate "$agent_name" 2>/dev/null) + + if [[ -z "$mandate" ]]; then + echo "Warning: No review mandate found for '$agent_name', using full prompt" >&2 + mandate=$("$SCRIPT_DIR/agent-registry.sh" prompt "$agent_name" 2>/dev/null) + fi + + # Get PR diff + local diff + diff=$(gh pr diff "$pr_number" 2>/dev/null) + + if [[ -z "$diff" ]]; then + echo "Error: Could not get diff for PR #$pr_number" >&2 + return 1 + fi + + # Build the review prompt + local prompt + prompt="You are reviewing PR #${pr_number} on branch '${branch}'. + +## Your Review Mandate + +${mandate} + +## PR Diff + +\`\`\`diff +${diff} +\`\`\` + +## Instructions + +1. Review the diff against your mandate +2. Provide specific, actionable feedback +3. End your review with exactly one of: + - RESULT: PASS — if the changes meet all criteria + - RESULT: FAIL — if there are blocking issues + - RESULT: CONDITIONAL PASS — if there are non-blocking suggestions" + + # Invoke Claude with the agent's review + local result + if command -v claude &>/dev/null; then + result=$(echo "$prompt" | claude --print 2>/dev/null) || true + else + echo "Error: claude CLI not found. Cannot run quality gates." >&2 + return 1 + fi + + echo "$result" + + # Parse result + if echo "$result" | grep -qE 'RESULT:[[:space:]]*PASS'; then + return 0 + elif echo "$result" | grep -qE 'RESULT:[[:space:]]*CONDITIONAL PASS'; then + return 0 + else + return 1 + fi +} + +# Run all applicable gates for a PR +run_gates() { + local pr_number="$1" branch="$2" + + if [[ -z "$pr_number" || -z "$branch" ]]; then + echo "Usage: run_gates " >&2 + return 1 + fi + + local gate_names all_passed=true blocking_failed=false + gate_names=$(_gate_names) || return 1 + + # Track results + declare -A results + + echo "=== Quality Gates for PR #${pr_number} (branch: ${branch}) ===" >&2 + + while IFS= read -r gate; do + [[ -z "$gate" ]] && continue + + if ! needs_gate "$gate" "$pr_number" 2>/dev/null; then + echo " SKIP: $gate (trigger not met)" >&2 + results[$gate]="skipped" + continue + fi + + echo " RUN: $gate" >&2 + + # Update delivery state if available + if [[ -f "$SCRIPT_DIR/delivery-state.sh" ]]; then + "$SCRIPT_DIR/delivery-state.sh" set-gate "$pr_number" "$gate" "pending" 2>/dev/null || true + fi + + local gate_exit=0 + run_gate "$gate" "$pr_number" "$branch" >/dev/null 2>&1 || gate_exit=$? + + if [[ $gate_exit -eq 0 ]]; then + results[$gate]="passed" + echo " PASS: $gate" >&2 + if [[ -f "$SCRIPT_DIR/delivery-state.sh" ]]; then + "$SCRIPT_DIR/delivery-state.sh" set-gate "$pr_number" "$gate" "passed" 2>/dev/null || true + fi + else + results[$gate]="failed" + all_passed=false + echo " FAIL: $gate" >&2 + if [[ -f "$SCRIPT_DIR/delivery-state.sh" ]]; then + "$SCRIPT_DIR/delivery-state.sh" set-gate "$pr_number" "$gate" "failed" 2>/dev/null || true + fi + + if _gate_blocking "$gate" 2>/dev/null; then + blocking_failed=true + echo " ^^^ BLOCKING gate failed" >&2 + fi + fi + done <<< "$gate_names" + + # Summary + echo "" >&2 + echo "=== Gate Summary ===" >&2 + for gate in "${!results[@]}"; do + printf " %-20s %s\n" "$gate:" "${results[$gate]}" >&2 + done + + if [[ "$blocking_failed" == true ]]; then + echo "RESULT: BLOCKED (blocking gate failed)" >&2 + return 1 + elif [[ "$all_passed" == true ]]; then + echo "RESULT: PASSED" >&2 + return 0 + else + echo "RESULT: PASSED (non-blocking failures)" >&2 + return 0 + fi +} + +# Get aggregate gate status for a PR (from delivery state) +gate_status() { + local pr_number="$1" + if [[ -z "$pr_number" ]]; then + echo "Usage: gate_status " >&2 + return 1 + fi + + if [[ -f "$SCRIPT_DIR/delivery-state.sh" ]]; then + local entry + entry=$("$SCRIPT_DIR/delivery-state.sh" get "$pr_number" 2>/dev/null) || true + if [[ -n "$entry" ]]; then + echo "$entry" | jq '.gates' + return 0 + fi + fi + + echo "No gate status found for PR #$pr_number" >&2 + return 1 +} + +# CLI dispatch +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + case "${1:-}" in + run-all) shift; run_gates "$@" ;; + run) shift; run_gate "$@" ;; + status) shift; gate_status "$@" ;; + needs) shift; needs_gate "$@" ;; + *) + echo "Usage: gate-runner.sh {run-all|run|status|needs} [args...]" >&2 + exit 1 + ;; + esac +fi diff --git a/deliver/scripts/pr-manager.sh b/deliver/scripts/pr-manager.sh new file mode 100755 index 0000000..c5a68fa --- /dev/null +++ b/deliver/scripts/pr-manager.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# PR lifecycle management for the delivery pipeline +# Wraps `gh` CLI for PR creation, status, merge, and label management. +# +# Usage: +# pr-manager.sh create [--title ] [--body <body>] [--base <base>] +# pr-manager.sh status <pr-number> +# pr-manager.sh merge <pr-number> [--method squash|merge|rebase] [--delete-branch] +# pr-manager.sh files <pr-number> +# pr-manager.sh add-label <pr-number> <label> +# pr-manager.sh remove-label <pr-number> <label> + +set -eo pipefail + +# Verify gh CLI is available +_require_gh() { + if ! command -v gh &>/dev/null; then + echo "Error: GitHub CLI (gh) is required but not installed." >&2 + echo "Install: https://cli.github.com/" >&2 + return 1 + fi + if ! gh auth status &>/dev/null 2>&1; then + echo "Error: Not authenticated with GitHub. Run 'gh auth login'." >&2 + return 1 + fi +} + +# Auto-detect repo from current git context +_detect_repo() { + gh repo view --json nameWithOwner -q '.nameWithOwner' 2>/dev/null +} + +# Create a PR for a branch +pr_create() { + _require_gh + + # Auto-detect default branch (main or master) + local default_base + default_base=$(gh repo view --json defaultBranchRef -q '.defaultBranchRef.name' 2>/dev/null || echo "main") + local branch="" title="" body="" base="$default_base" + + # First positional arg is branch + if [[ $# -gt 0 && ! "$1" =~ ^-- ]]; then + branch="$1"; shift + fi + + while [[ $# -gt 0 ]]; do + case "$1" in + --title) title="$2"; shift 2 ;; + --body) body="$2"; shift 2 ;; + --base) base="$2"; shift 2 ;; + *) shift ;; + esac + done + + if [[ -z "$branch" ]]; then + echo "Usage: pr_create <branch> [--title <title>] [--body <body>] [--base <base>]" >&2 + return 1 + fi + + # Default title from branch name + if [[ -z "$title" ]]; then + title="${branch//[-_]/ }" + fi + + local args=( + --head "$branch" + --base "$base" + --title "$title" + ) + + if [[ -n "$body" ]]; then + args+=(--body "$body") + fi + + local result + result=$(gh pr create "${args[@]}" 2>&1) + local exit_code=$? + + if [[ $exit_code -ne 0 ]]; then + echo "Error creating PR: $result" >&2 + return 1 + fi + + # Extract PR number from URL + local pr_number + pr_number=$(echo "$result" | grep -oE '/pull/[0-9]+' | grep -oE '[0-9]+' | tail -1) + + if [[ -n "$pr_number" ]]; then + echo "$pr_number" + else + echo "$result" + fi +} + +# Get PR status as JSON +pr_status() { + _require_gh + + local pr_number="$1" + if [[ -z "$pr_number" ]]; then + echo "Usage: pr_status <pr-number>" >&2 + return 1 + fi + + gh pr view "$pr_number" --json number,url,state,statusCheckRollup,labels,additions,deletions \ + | jq '{ + number: .number, + url: .url, + state: (.state | ascii_downcase), + ciStatus: ( + if (.statusCheckRollup | length) == 0 then "unknown" + elif (.statusCheckRollup | all(.conclusion == "SUCCESS" or .conclusion == "NEUTRAL" or .conclusion == "SKIPPED")) then "passed" + elif (.statusCheckRollup | any(.conclusion == "FAILURE" or .conclusion == "ERROR")) then "failed" + else "pending" + end + ), + labels: [.labels[].name], + additions: .additions, + deletions: .deletions + }' +} + +# Merge a PR +pr_merge() { + _require_gh + + local pr_number="" method="squash" delete_branch=false + + if [[ $# -gt 0 && ! "$1" =~ ^-- ]]; then + pr_number="$1"; shift + fi + + while [[ $# -gt 0 ]]; do + case "$1" in + --method) method="$2"; shift 2 ;; + --delete-branch) delete_branch=true; shift ;; + *) shift ;; + esac + done + + if [[ -z "$pr_number" ]]; then + echo "Usage: pr_merge <pr-number> [--method squash|merge|rebase] [--delete-branch]" >&2 + return 1 + fi + + local args=("$pr_number" "--$method") + + if [[ "$delete_branch" == true ]]; then + args+=(--delete-branch) + fi + + gh pr merge "${args[@]}" +} + +# Update a PR's branch with the latest base branch changes +pr_update_branch() { + _require_gh + + local pr_number="$1" + if [[ -z "$pr_number" ]]; then + echo "Usage: pr_update_branch <pr-number>" >&2 + return 1 + fi + + local head_sha + head_sha=$(gh pr view "$pr_number" --json headRefOid -q '.headRefOid') + + local repo + repo=$(_detect_repo) + + gh api "repos/${repo}/pulls/${pr_number}/update-branch" \ + -X PUT -f expected_head_sha="$head_sha" 2>&1 +} + +# List files changed in a PR +pr_files() { + _require_gh + + local pr_number="$1" + if [[ -z "$pr_number" ]]; then + echo "Usage: pr_files <pr-number>" >&2 + return 1 + fi + + gh pr diff "$pr_number" --name-only +} + +# Add a label to a PR +pr_add_label() { + _require_gh + + local pr_number="$1" label="$2" + if [[ -z "$pr_number" || -z "$label" ]]; then + echo "Usage: pr_add_label <pr-number> <label>" >&2 + return 1 + fi + + gh pr edit "$pr_number" --add-label "$label" +} + +# Remove a label from a PR +pr_remove_label() { + _require_gh + + local pr_number="$1" label="$2" + if [[ -z "$pr_number" || -z "$label" ]]; then + echo "Usage: pr_remove_label <pr-number> <label>" >&2 + return 1 + fi + + gh pr edit "$pr_number" --remove-label "$label" +} + +# CLI dispatch +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + case "${1:-}" in + create) shift; pr_create "$@" ;; + status) shift; pr_status "$@" ;; + merge) shift; pr_merge "$@" ;; + update-branch) shift; pr_update_branch "$@" ;; + files) shift; pr_files "$@" ;; + add-label) shift; pr_add_label "$@" ;; + remove-label) shift; pr_remove_label "$@" ;; + *) + echo "Usage: pr-manager.sh {create|status|merge|update-branch|files|add-label|remove-label} [args...]" >&2 + exit 1 + ;; + esac +fi diff --git a/deliver/scripts/run.sh b/deliver/scripts/run.sh new file mode 100755 index 0000000..4a94624 --- /dev/null +++ b/deliver/scripts/run.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +# End-to-end delivery pipeline runner +# Orchestrates: branch validation -> PR creation -> CI polling -> quality gates -> merge +# +# Usage: +# run.sh <branch> [--task-id ID] [--base BASE] [--no-merge] +# run.sh resume <task-id> +# run.sh status <task-id> +# +# Output protocol (stdout, pipe-delimited): +# PIPELINE|<task-id>|PHASE|<state>|<message> +# PIPELINE|<task-id>|GATE|<agent>|<result> +# PIPELINE|<task-id>|RESULT|<final-state>|<message> + +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source sibling pipeline scripts +# shellcheck source=./delivery-state.sh +source "$SCRIPT_DIR/delivery-state.sh" +# shellcheck source=./pr-manager.sh +source "$SCRIPT_DIR/pr-manager.sh" +# shellcheck source=./ci-monitor.sh +source "$SCRIPT_DIR/ci-monitor.sh" +# shellcheck source=./gate-runner.sh +source "$SCRIPT_DIR/gate-runner.sh" + +# Load merge settings from gates.yaml +_load_merge_settings() { + local gates_file="${GATES_CONFIG:-$HOME/.gstack/deliver/gates.yaml}" + if [[ ! -f "$gates_file" ]]; then + gates_file="$SCRIPT_DIR/../gates.yaml.example" + fi + + MERGE_METHOD="squash" + DELETE_BRANCH_ON_MERGE=true + AUTO_MERGE=true + + if [[ -f "$gates_file" ]]; then + local val + val=$(grep 'merge_method:' "$gates_file" 2>/dev/null | awk '{print $2}') + [[ -n "$val" ]] && MERGE_METHOD="$val" + + val=$(grep 'delete_branch_on_merge:' "$gates_file" 2>/dev/null | awk '{print $2}') + [[ "$val" == "false" ]] && DELETE_BRANCH_ON_MERGE=false + + val=$(grep 'auto_merge:' "$gates_file" 2>/dev/null | awk '{print $2}') + [[ "$val" == "false" ]] && AUTO_MERGE=false + fi + return 0 +} + +# Emit structured pipeline output +_emit() { + local task_id="$1" type="$2" key="$3" value="$4" + echo "PIPELINE|${task_id}|${type}|${key}|${value}" +} + +# Validate that a branch is suitable for delivery +_validate_branch() { + local branch="$1" + + # Reject main/master + if [[ "$branch" == "main" || "$branch" == "master" ]]; then + echo "Error: Cannot deliver main/master branch" >&2 + return 1 + fi + + # Check branch exists remotely + if ! git ls-remote --heads origin "$branch" 2>/dev/null | grep -q "$branch"; then + echo "Error: Branch '$branch' not found on remote" >&2 + return 1 + fi + + return 0 +} + +# Find existing open PR for a branch +_find_existing_pr() { + local branch="$1" + gh pr list --head "$branch" --state open --json number -q '.[0].number' 2>/dev/null +} + +# Run the full pipeline for a branch +pipeline_run() { + local branch="" task_id="" base="" no_merge=false + + # Parse args + if [[ $# -gt 0 && ! "$1" =~ ^-- ]]; then + branch="$1"; shift + fi + + while [[ $# -gt 0 ]]; do + case "$1" in + --task-id) task_id="$2"; shift 2 ;; + --base) base="$2"; shift 2 ;; + --no-merge) no_merge=true; shift ;; + *) shift ;; + esac + done + + if [[ -z "$branch" ]]; then + echo "Usage: pipeline_run <branch> [--task-id ID] [--base BASE] [--no-merge]" >&2 + return 1 + fi + + # Auto-detect base branch if not specified + if [[ -z "$base" ]]; then + base=$(gh repo view --json defaultBranchRef -q '.defaultBranchRef.name' 2>/dev/null || echo "main") + fi + + # Default task ID from branch name + task_id="${task_id:-$branch}" + + _load_merge_settings + + # Step 1: Validate branch + _validate_branch "$branch" || return 1 + + # Step 2: Initialize delivery tracking + delivery_init "$task_id" "$branch" >/dev/null 2>&1 || true + _emit "$task_id" "PHASE" "PR_CREATING" "Looking for PR..." + + # Step 3: Create or find PR + delivery_transition "$task_id" "PR_CREATING" 2>/dev/null || true + local pr_number + pr_number=$(_find_existing_pr "$branch") + + if [[ -z "$pr_number" ]]; then + _emit "$task_id" "PHASE" "PR_CREATING" "Creating PR for $branch..." + pr_number=$(pr_create "$branch" --base "$base") || { + _emit "$task_id" "RESULT" "BLOCKED" "Failed to create PR" + delivery_transition "$task_id" "BLOCKED" 2>/dev/null || true + return 1 + } + fi + + delivery_set_pr "$task_id" "$pr_number" 2>/dev/null || true + _emit "$task_id" "PHASE" "CI_RUNNING" "PR #${pr_number} — polling CI..." + + # Step 4: Poll CI + delivery_transition "$task_id" "CI_RUNNING" 2>/dev/null || true + local ci_result + ci_result=$(ci_poll "$pr_number") || { + local ci_exit=$? + if [[ $ci_exit -eq 1 ]]; then + _emit "$task_id" "RESULT" "BLOCKED" "PR #${pr_number} CI failed" + delivery_transition "$task_id" "BLOCKED" 2>/dev/null || true + return 1 + elif [[ $ci_exit -eq 2 ]]; then + _emit "$task_id" "RESULT" "BLOCKED" "PR #${pr_number} CI timed out" + delivery_transition "$task_id" "BLOCKED" 2>/dev/null || true + return 1 + fi + } + + if [[ "$ci_result" != "passed" ]]; then + _emit "$task_id" "RESULT" "BLOCKED" "PR #${pr_number} CI status: ${ci_result}" + delivery_transition "$task_id" "BLOCKED" 2>/dev/null || true + return 1 + fi + + # Step 5: Run quality gates + _emit "$task_id" "PHASE" "REVIEWING" "Running quality gates..." + delivery_transition "$task_id" "REVIEWING" 2>/dev/null || true + + if run_gates "$pr_number" "$branch" 2>/dev/null; then + _emit "$task_id" "GATE" "all" "passed" + delivery_transition "$task_id" "APPROVED" 2>/dev/null || true + else + _emit "$task_id" "RESULT" "BLOCKED" "PR #${pr_number} blocked by quality gates" + delivery_transition "$task_id" "BLOCKED" 2>/dev/null || true + return 1 + fi + + # Step 6: Merge (unless --no-merge) + if [[ "$no_merge" == true ]]; then + _emit "$task_id" "RESULT" "APPROVED" "PR #${pr_number} approved (merge skipped)" + return 0 + fi + + if [[ "$AUTO_MERGE" == false ]]; then + _emit "$task_id" "RESULT" "APPROVED" "PR #${pr_number} approved (auto-merge disabled)" + return 0 + fi + + _emit "$task_id" "PHASE" "MERGING" "Merging PR #${pr_number}..." + delivery_transition "$task_id" "MERGING" 2>/dev/null || true + + local merge_args=("$pr_number" "--method" "$MERGE_METHOD") + if [[ "$DELETE_BRANCH_ON_MERGE" == true ]]; then + merge_args+=(--delete-branch) + fi + + if pr_merge "${merge_args[@]}" 2>/dev/null; then + delivery_transition "$task_id" "MERGED" 2>/dev/null || true + _emit "$task_id" "RESULT" "MERGED" "PR #${pr_number} merged" + return 0 + else + _emit "$task_id" "RESULT" "BLOCKED" "PR #${pr_number} merge failed" + delivery_transition "$task_id" "BLOCKED" 2>/dev/null || true + return 1 + fi +} + +# Resume a pipeline from its saved delivery state +pipeline_resume() { + local task_id="$1" + if [[ -z "$task_id" ]]; then + echo "Usage: pipeline_resume <task-id>" >&2 + return 1 + fi + + local entry + entry=$(delivery_get "$task_id" 2>/dev/null) || { + echo "Error: No delivery found for task '$task_id'" >&2 + return 1 + } + + local branch state pr_number + branch=$(echo "$entry" | jq -r '.branch') + state=$(echo "$entry" | jq -r '.state') + pr_number=$(echo "$entry" | jq -r '.prNumber // empty') + + _load_merge_settings + + case "$state" in + WORKING|PR_CREATING) + pipeline_run "$branch" --task-id "$task_id" + ;; + CI_RUNNING) + if [[ -z "$pr_number" ]]; then + echo "Error: No PR number recorded for $task_id" >&2 + return 1 + fi + # Re-enter from CI polling + _emit "$task_id" "PHASE" "CI_RUNNING" "Resuming CI poll for PR #${pr_number}..." + local ci_result + ci_result=$(ci_poll "$pr_number") || { + delivery_transition "$task_id" "BLOCKED" 2>/dev/null || true + return 1 + } + if [[ "$ci_result" == "passed" ]]; then + delivery_transition "$task_id" "REVIEWING" 2>/dev/null || true + if run_gates "$pr_number" "$branch" 2>/dev/null; then + delivery_transition "$task_id" "APPROVED" 2>/dev/null || true + delivery_transition "$task_id" "MERGING" 2>/dev/null || true + pr_merge "$pr_number" --method "$MERGE_METHOD" --delete-branch 2>/dev/null && \ + delivery_transition "$task_id" "MERGED" 2>/dev/null || true + fi + fi + ;; + REVIEWING) + [[ -z "$pr_number" ]] && return 1 + if run_gates "$pr_number" "$branch" 2>/dev/null; then + delivery_transition "$task_id" "APPROVED" 2>/dev/null || true + delivery_transition "$task_id" "MERGING" 2>/dev/null || true + pr_merge "$pr_number" --method "$MERGE_METHOD" --delete-branch 2>/dev/null && \ + delivery_transition "$task_id" "MERGED" 2>/dev/null || true + fi + ;; + BLOCKED) + # Re-run from the beginning + delivery_transition "$task_id" "CI_RUNNING" 2>/dev/null || \ + delivery_transition "$task_id" "WORKING" 2>/dev/null || true + pipeline_run "$branch" --task-id "$task_id" + ;; + APPROVED|MERGING) + [[ -z "$pr_number" ]] && return 1 + delivery_transition "$task_id" "MERGING" 2>/dev/null || true + pr_merge "$pr_number" --method "$MERGE_METHOD" --delete-branch 2>/dev/null && \ + delivery_transition "$task_id" "MERGED" 2>/dev/null || true + ;; + MERGED) + _emit "$task_id" "RESULT" "MERGED" "Already merged" + return 0 + ;; + esac +} + +# Show pipeline status for a task +pipeline_status() { + local task_id="$1" + if [[ -z "$task_id" ]]; then + echo "Usage: pipeline_status <task-id>" >&2 + return 1 + fi + + delivery_get "$task_id" +} + +# CLI dispatch +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + case "${1:-}" in + resume) shift; pipeline_resume "$@" ;; + status) shift; pipeline_status "$@" ;; + *) + # Default: run pipeline with first arg as branch + pipeline_run "$@" + ;; + esac +fi