Skip to content

DL-6: Comprehensive Story Outlines & Plot Threads #43

DL-6: Comprehensive Story Outlines & Plot Threads

DL-6: Comprehensive Story Outlines & Plot Threads #43

name: Copilot Automation
# =============================================================================
# COPILOT AUTOMATION LOOP
# =============================================================================
#
# This workflow automates the full development cycle with GitHub Copilot:
#
# ┌─────────────────────────────────────────────────────────────────────────┐
# │ │
# │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
# │ │ Queue │───▶│ Copilot │───▶│ CI │───▶│ Review │ │
# │ │ Issue │ │ Opens │ │ Tests │ │ PR │ │
# │ └──────────┘ │ PR │ └────┬─────┘ └────┬─────┘ │
# │ ▲ └──────────┘ │ │ │
# │ │ │ │ │
# │ │ ┌────▼─────┐ │ │
# │ │ │ Failed? │ │ │
# │ │ └────┬─────┘ │ │
# │ │ │ │ │
# │ │ ┌──────────┐ YES │ NO │ │
# │ │ │ Copilot │◀─────────┤ │ │
# │ │ │ Fixes │ │ │ │
# │ │ │ Code │──────────┘ │ │
# │ │ └──────────┘ │ │
# │ │ ▲ │ │
# │ │ │ (max 3 attempts) │ │
# │ │ │ │ │
# │ │ ┌────┴─────┐ │ │
# │ │ │ Still │ ┌────▼─────┐ │
# │ │ │ Failing? │────YES─────────────▶│ Human │ │
# │ │ └──────────┘ │ Review │ │
# │ │ └────┬─────┘ │
# │ │ │ │
# │ ┌────┴─────┐ ┌──────────┐ ┌──────────┐ │ │
# │ │ Update │◀───│ Merge │◀───│ Approved │◀────────┘ │
# │ │ Status │ │ PR │ │ ? │ │
# │ └──────────┘ └──────────┘ └──────────┘ │
# │ │ │
# │ └───────────────────────────────────────────────────────────────┘
# │ (loop to next issue)
# │
# └─────────────────────────────────────────────────────────────────────────┘
#
# JOBS:
# 1. queue-issues - Find ready issues (dependencies met), label for Copilot
# 2. pause-copilot - Budget protection: remove labels to pause queue
# 3. show-status - Display current queue and budget status
# 4. copilot-review - Request Copilot review on PRs
# 5. auto-merge - Enable auto-merge when approved
# 6. update-on-merge - Update YAML status, queue next issue
# 7. scheduled-queue - Daily check to keep queue populated
#
# NOTE: CI failure handling moved to ci-failure-handler.yml (separate workflow)
#
# BUDGET PROTECTION:
# - Tracks Copilot PRs per month
# - Pauses queue when limit reached
# - Escalates to human after 3 failed fix attempts
#
on:
# Manual trigger to queue next issues
workflow_dispatch:
inputs:
action:
description: 'Action to perform'
required: true
default: 'queue'
type: choice
options:
- queue # Find and label ready issues
- pause # Remove copilot labels (budget protection)
- status # Show current queue status
max_issues:
description: 'Max issues to queue'
required: false
default: '1'
type: string
# Auto-trigger on schedule
schedule:
- cron: '0 8 * * 1-5' # Weekdays at 8am UTC
# Trigger review when Copilot opens a PR
pull_request:
types: [opened, synchronize, ready_for_review]
# Trigger status update on merge
pull_request_target:
types: [closed]
env:
PROJECT_NUMBER: 1
PROJECT_OWNER: spuentesp
MAX_COPILOT_PRS_PER_MONTH: 30 # Budget protection
jobs:
# ============================================================
# JOB 1: Queue issues for Copilot
# ============================================================
queue-issues:
if: github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'queue'
runs-on: ubuntu-latest
permissions:
issues: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install pyyaml
- name: Check budget
id: budget
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Count Copilot PRs this month
MONTH_START=$(date -d "$(date +%Y-%m-01)" +%Y-%m-%d)
COUNT=$(gh pr list \
--author "app/github-copilot" \
--state all \
--search "created:>=$MONTH_START" \
--json number \
--jq 'length' 2>/dev/null || echo "0")
echo "Copilot PRs this month: $COUNT / $MAX_COPILOT_PRS_PER_MONTH"
if [ "$COUNT" -ge "$MAX_COPILOT_PRS_PER_MONTH" ]; then
echo "budget_ok=false" >> $GITHUB_OUTPUT
echo "::warning::Monthly Copilot budget exhausted!"
else
echo "budget_ok=true" >> $GITHUB_OUTPUT
REMAINING=$((MAX_COPILOT_PRS_PER_MONTH - COUNT))
echo "remaining=$REMAINING" >> $GITHUB_OUTPUT
fi
- name: Queue ready issues
if: steps.budget.outputs.budget_ok == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MAX_ISSUES: ${{ github.event.inputs.max_issues }}
run: |
python3 << 'EOF'
import os
import json
import subprocess
from pathlib import Path
import yaml
def run_gh(args):
result = subprocess.run(
["gh"] + args,
capture_output=True, text=True
)
return result.stdout.strip()
def get_use_cases():
"""Load all YAML use cases."""
cases = {}
for f in Path("docs/use-cases").rglob("*.yml"):
try:
data = yaml.safe_load(f.read_text())
if data and "id" in data:
cases[data["id"]] = data
except Exception as e:
print(f"Warning: Could not parse {f}: {e}")
return cases
def get_existing_prs():
"""Get list of open PRs to avoid duplicate work."""
result = run_gh(["pr", "list", "--state", "open", "--json", "title"])
prs = json.loads(result) if result else []
return [pr["title"] for pr in prs]
def get_issues_with_label(label):
"""Get issues already labeled."""
result = run_gh(["issue", "list", "--label", label, "--state", "open", "--json", "number,title"])
return json.loads(result) if result else []
def find_ready_issues(cases):
"""Find issues whose dependencies are all done."""
ready = []
for id, case in cases.items():
status = case.get("status", "todo")
if status == "done":
continue
deps = case.get("depends_on", [])
deps_done = all(
cases.get(d, {}).get("status") == "done"
for d in deps
)
if deps_done:
priority = case.get("priority", "medium")
ready.append((priority, id, case))
# Sort by priority
priority_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
ready.sort(key=lambda x: priority_order.get(x[0], 2))
return ready
def find_github_issue(use_case_id):
"""Find GitHub issue number for a use case."""
result = run_gh([
"issue", "list",
"--search", f'"{use_case_id}" in:title',
"--state", "open",
"--json", "number,title",
"--limit", "5"
])
issues = json.loads(result) if result else []
for issue in issues:
if issue["title"].startswith(f"{use_case_id}:"):
return issue["number"]
return None
def label_issue(issue_num, labels):
"""Add labels to an issue."""
for label in labels:
run_gh(["issue", "edit", str(issue_num), "--add-label", label])
def assign_copilot(issue_num):
"""Assign Copilot to an issue and trigger it to start."""
# Assign Copilot
run_gh(["issue", "edit", str(issue_num), "--add-assignee", "Copilot"])
# Comment to trigger Copilot
run_gh(["issue", "comment", str(issue_num), "--body",
"@copilot please implement this issue following the acceptance criteria and implementation details."])
# Main logic
cases = get_use_cases()
print(f"Loaded {len(cases)} use cases")
ready = find_ready_issues(cases)
print(f"Found {len(ready)} ready issues (dependencies met)")
existing_prs = get_existing_prs()
already_labeled = get_issues_with_label("copilot")
max_to_queue = int(os.environ.get("MAX_ISSUES", "1"))
queued = 0
for priority, use_case_id, case in ready:
if queued >= max_to_queue:
break
# Skip if PR already exists
if any(use_case_id in pr for pr in existing_prs):
print(f" Skipping {use_case_id}: PR already exists")
continue
# Skip if already labeled
if any(use_case_id in issue.get("title", "") for issue in already_labeled):
print(f" Skipping {use_case_id}: already labeled for Copilot")
continue
issue_num = find_github_issue(use_case_id)
if issue_num:
print(f" Assigning #{issue_num} ({use_case_id}) to Copilot")
label_issue(issue_num, ["copilot", "ready-for-copilot"])
assign_copilot(issue_num)
queued += 1
else:
print(f" Warning: Could not find GitHub issue for {use_case_id}")
print(f"\nQueued {queued} issues for Copilot")
EOF
# ============================================================
# JOB 2: Pause Copilot (budget protection)
# ============================================================
pause-copilot:
if: github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'pause'
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Remove Copilot labels
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Removing 'copilot' and 'ready-for-copilot' labels..."
gh issue list --label "copilot" --state open --json number --jq '.[].number' | \
xargs -I{} gh issue edit {} --remove-label "copilot" --remove-label "ready-for-copilot" 2>/dev/null || true
echo "Copilot queue paused"
# ============================================================
# JOB 3: Show queue status
# ============================================================
show-status:
if: github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'status'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Show status
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "=== Copilot Queue Status ==="
echo ""
echo "Issues labeled for Copilot:"
gh issue list --label "copilot" --state open --json number,title --jq '.[] | " #\(.number): \(.title)"'
echo ""
echo "Open Copilot PRs:"
gh pr list --author "app/github-copilot" --state open --json number,title --jq '.[] | " #\(.number): \(.title)"' || echo " None"
echo ""
MONTH_START=$(date -d "$(date +%Y-%m-01)" +%Y-%m-%d)
COUNT=$(gh pr list --author "app/github-copilot" --state all --search "created:>=$MONTH_START" --json number --jq 'length' 2>/dev/null || echo "0")
echo "Copilot PRs this month: $COUNT / $MAX_COPILOT_PRS_PER_MONTH"
# ============================================================
# JOB 4: Auto-approve and review Copilot PRs
# ============================================================
copilot-review:
if: |
github.event_name == 'pull_request' &&
github.event.pull_request.draft == false &&
github.event.action != 'closed'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Check if Copilot PR needs approval
id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUM="${{ github.event.pull_request.number }}"
AUTHOR="${{ github.event.pull_request.user.login }}"
# Check if this is a Copilot PR (by author or label)
LABELS=$(gh pr view "$PR_NUM" --json labels --jq '.labels[].name' 2>/dev/null || echo "")
if echo "$LABELS" | grep -q "copilot"; then
echo "is_copilot_pr=true" >> $GITHUB_OUTPUT
elif [[ "$AUTHOR" == *"copilot"* ]] || [[ "$AUTHOR" == *"bot"* ]]; then
echo "is_copilot_pr=true" >> $GITHUB_OUTPUT
else
echo "is_copilot_pr=false" >> $GITHUB_OUTPUT
fi
# Check if already approved
REVIEW_STATE=$(gh pr view "$PR_NUM" --json reviewDecision --jq '.reviewDecision' 2>/dev/null || echo "")
if [ "$REVIEW_STATE" = "APPROVED" ]; then
echo "already_approved=true" >> $GITHUB_OUTPUT
else
echo "already_approved=false" >> $GITHUB_OUTPUT
fi
- name: Wait for CI to pass before approving
if: steps.check.outputs.is_copilot_pr == 'true' && steps.check.outputs.already_approved == 'false'
id: wait-validate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUM="${{ github.event.pull_request.number }}"
SHA="${{ github.event.pull_request.head.sha }}"
echo "Waiting for validate check to pass..."
# Wait up to 5 minutes for validate to complete
for i in {1..30}; do
# Check specifically for the validate job
VALIDATE_STATUS=$(gh api repos/${{ github.repository }}/commits/$SHA/check-runs \
--jq '.check_runs[] | select(.name == "validate") | .conclusion' 2>/dev/null || echo "pending")
echo " Attempt $i: validate=$VALIDATE_STATUS"
if [ "$VALIDATE_STATUS" = "success" ]; then
echo "Validate passed!"
echo "ci_passed=true" >> $GITHUB_OUTPUT
exit 0
elif [ "$VALIDATE_STATUS" = "failure" ]; then
echo "Validate failed, skipping approval"
echo "ci_passed=false" >> $GITHUB_OUTPUT
exit 0
fi
sleep 10
done
echo "Timeout waiting for validate"
echo "ci_passed=false" >> $GITHUB_OUTPUT
- name: Auto-approve Copilot PR
if: |
steps.check.outputs.is_copilot_pr == 'true' &&
steps.check.outputs.already_approved == 'false' &&
steps.wait-validate.outputs.ci_passed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUM="${{ github.event.pull_request.number }}"
echo "Auto-approving Copilot PR #$PR_NUM"
# Approve the PR
gh pr review "$PR_NUM" --approve --body "✅ **Auto-approved by CI bot**
This PR was created by GitHub Copilot and has passed all CI checks:
- ✅ Layer boundary validation
- ✅ Linting (ruff, black)
- ✅ Type checking (mypy)
- ✅ Unit tests with coverage
Auto-merge will proceed if enabled."
- name: Request Copilot code review
if: steps.check.outputs.is_copilot_pr == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUM="${{ github.event.pull_request.number }}"
# Add label and request Copilot review for additional feedback
gh pr edit "$PR_NUM" --add-label "copilot-review-requested" 2>/dev/null || true
# Add a comment to trigger Copilot review
gh pr comment "$PR_NUM" --body "@github-copilot please review this PR and check:
1. Code follows the patterns in CLAUDE.md and ARCHITECTURE.md
2. Tests cover the acceptance criteria
3. No layer boundary violations
4. Authority rules are respected (CanonKeeper for Neo4j writes)" 2>/dev/null || true
# ============================================================
# JOB 5: Auto-merge approved Copilot PRs
# ============================================================
auto-merge:
needs: [copilot-review]
if: |
github.event_name == 'pull_request' &&
(github.event.action == 'synchronize' || github.event.action == 'ready_for_review')
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v4
- name: Wait for approval and merge
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUM="${{ github.event.pull_request.number }}"
# Check if PR has copilot label
LABELS=$(gh pr view "$PR_NUM" --json labels --jq '.labels[].name' 2>/dev/null || echo "")
if ! echo "$LABELS" | grep -q "copilot"; then
echo "Not a Copilot PR, skipping auto-merge"
exit 0
fi
# Wait a bit for the approval to be registered
sleep 5
# Check review status
REVIEW_STATE=$(gh pr view "$PR_NUM" --json reviewDecision --jq '.reviewDecision' 2>/dev/null || echo "")
if [ "$REVIEW_STATE" != "APPROVED" ]; then
echo "PR not yet approved (state: $REVIEW_STATE), skipping"
exit 0
fi
# Check if CI passed
SHA="${{ github.event.pull_request.head.sha }}"
VALIDATE_STATUS=$(gh api repos/${{ github.repository }}/commits/$SHA/check-runs \
--jq '.check_runs[] | select(.name == "validate") | .conclusion' 2>/dev/null || echo "pending")
if [ "$VALIDATE_STATUS" != "success" ]; then
echo "Validate not passed yet (status: $VALIDATE_STATUS), skipping"
exit 0
fi
echo "All conditions met, merging PR #$PR_NUM"
# Try auto-merge first, fall back to direct merge
if ! gh pr merge "$PR_NUM" --squash --auto 2>/dev/null; then
echo "Auto-merge not available, trying direct merge..."
gh pr merge "$PR_NUM" --squash --admin || echo "Could not merge, may need manual intervention"
fi
# ============================================================
# JOB 6: Update status on merge
# ============================================================
update-on-merge:
if: |
github.event_name == 'pull_request_target' &&
github.event.action == 'closed' &&
github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract use case ID
id: extract
run: |
TITLE="${{ github.event.pull_request.title }}"
# Extract ID like "DL-24" or "P-1" from PR title
ID=$(echo "$TITLE" | grep -oE '^[A-Z]+-[0-9]+' || echo "")
if [ -n "$ID" ]; then
echo "use_case_id=$ID" >> $GITHUB_OUTPUT
echo "Found use case: $ID"
else
echo "No use case ID found in title"
echo "use_case_id=" >> $GITHUB_OUTPUT
fi
- name: Update YAML status to done
if: steps.extract.outputs.use_case_id != ''
env:
USE_CASE_ID: ${{ steps.extract.outputs.use_case_id }}
run: |
# Find the YAML file
FILE=$(find docs/use-cases -name "${USE_CASE_ID}.yml" -o -name "${USE_CASE_ID}.yaml" | head -1)
if [ -n "$FILE" ] && [ -f "$FILE" ]; then
echo "Updating $FILE to status: done"
sed -i 's/status: "todo"/status: "done"/' "$FILE"
sed -i "s/status: 'todo'/status: 'done'/" "$FILE"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add "$FILE"
git commit -m "chore: mark ${USE_CASE_ID} as done [skip ci]" || echo "No changes to commit"
git push || echo "Nothing to push"
else
echo "YAML file not found for $USE_CASE_ID"
fi
- name: Remove Copilot labels from issue
if: steps.extract.outputs.use_case_id != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
USE_CASE_ID: ${{ steps.extract.outputs.use_case_id }}
run: |
# Find and clean up the issue
ISSUE_NUM=$(gh issue list \
--search "\"$USE_CASE_ID\" in:title" \
--state open \
--json number \
--jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$ISSUE_NUM" ]; then
gh issue edit "$ISSUE_NUM" \
--remove-label "copilot" \
--remove-label "ready-for-copilot" \
--remove-label "copilot-review-requested" 2>/dev/null || true
fi
- name: Queue next issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Triggering queue for next issue..."
gh workflow run copilot-automation.yml -f action=queue -f max_issues=1 || echo "Could not trigger next queue"
# ============================================================
# JOB 7: Scheduled queue check
# ============================================================
scheduled-queue:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
permissions:
issues: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Check and queue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Check budget first
MONTH_START=$(date -d "$(date +%Y-%m-01)" +%Y-%m-%d)
COUNT=$(gh pr list \
--author "app/github-copilot" \
--state all \
--search "created:>=$MONTH_START" \
--json number \
--jq 'length' 2>/dev/null || echo "0")
if [ "$COUNT" -ge "$MAX_COPILOT_PRS_PER_MONTH" ]; then
echo "Budget exhausted, skipping queue"
exit 0
fi
# Check if there are already labeled issues
LABELED=$(gh issue list --label "copilot" --state open --json number --jq 'length')
if [ "$LABELED" -eq 0 ]; then
echo "No issues in queue, triggering queue action"
gh workflow run copilot-automation.yml -f action=queue -f max_issues=1
else
echo "Already have $LABELED issues in queue"
fi