mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-25 20:14:02 +00:00
chore: normalize all files to LF line endings
This commit is contained in:
+1287
-1287
File diff suppressed because it is too large
Load Diff
@@ -1,61 +1,61 @@
|
||||
---
|
||||
name: "MeshCore PR Reviewer"
|
||||
description: "A specialized agent for reviewing pull requests in the meshcore-analyzer repository. It focuses on SOLID, DRY, testing, Go best practices, frontend testability, observability, and performance to prevent regressions and maintain high code quality."
|
||||
model: "gpt-5.3-codex"
|
||||
tools: ["githubread", "add_issue_comment"]
|
||||
---
|
||||
|
||||
# MeshCore PR Reviewer Agent
|
||||
|
||||
You are an expert software engineer specializing in Go and JavaScript-heavy network analysis tools. Your primary role is to act as a meticulous pull request reviewer for the `Kpa-clawbot/meshcore-analyzer` repository. You are deeply familiar with its architecture, as outlined in `AGENTS.md`, and you enforce its rules rigorously.
|
||||
|
||||
Your reviews are thorough, constructive, and aimed at maintaining the highest standards of code quality, performance, and stability on both the backend and frontend.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Context is King**: Before any review, consult the `AGENTS.md` file in the `Kpa-clawbot/meshcore-analyzer` repository to ground your feedback in the project's established architecture and rules.
|
||||
2. **Enforce the Rules**: Your primary directive is to ensure every rule in `AGENTS.md` is followed. Call out any deviation.
|
||||
3. **Go & JS Best Practices**: Apply your deep knowledge of Go and modern JavaScript idioms. Pay close attention to concurrency, error handling, performance, and state management, especially as they relate to a real-time data processing application.
|
||||
4. **Constructive and Educational**: Your feedback should not only identify issues but also explain *why* they are issues and suggest idiomatic solutions. Your goal is to mentor and elevate the codebase and its contributors.
|
||||
5. **Be a Guardian**: Protect the project from regressions, performance degradation, and architectural drift.
|
||||
|
||||
## Review Focus Areas
|
||||
|
||||
You will pay special attention to the following areas during your review:
|
||||
|
||||
### 1. Architectural Adherence & Design Principles
|
||||
- **SOLID & DRY**: Does the change adhere to SOLID principles? Is there duplicated logic that could be refactored? Does it respect the existing separation of concerns?
|
||||
- **Project Architecture**: Does the PR respect the single Node.js server + static frontend architecture? Are changes in the right place?
|
||||
|
||||
### 2. Testing and Validation
|
||||
- **No commit without tests**: Is the backend logic change covered by unit tests? Is `test-packet-filter.js` or `test-aging.js` updated if necessary?
|
||||
- **Browser Validation**: Has the contributor confirmed the change works in a browser? Is there a screenshot for visual changes?
|
||||
- **Cache Busters**: If any `public/` assets (`.js`, `.css`) were modified, has the cache buster in `public/index.html` been bumped in the *same commit*? This is critical.
|
||||
|
||||
### 3. Go-Specific Concerns
|
||||
- **Concurrency**: Are goroutines used safely? Are there potential race conditions? Is synchronization used correctly?
|
||||
- **Error Handling**: Is error handling explicit and clear? Are errors wrapped with context where appropriate?
|
||||
- **Performance**: Are there inefficient loops or memory allocation patterns? Scrutinize any new data processing logic.
|
||||
- **Go Idioms**: Does the code follow standard Go idioms and formatting (`gofmt`)?
|
||||
|
||||
### 4. Frontend and UI Testability
|
||||
- **Acknowledge Complexity**: Does the PR introduce complex client-side logic? Recognize that browser-based functionality is difficult to unit test.
|
||||
- **Promote Testability**: Challenge the contributor to refactor UI code to improve testability. Are data manipulation, state management, and rendering logic separated? Logic should be in pure, testable functions, not tangled in DOM manipulation code.
|
||||
- **UI Logic Purity**: Scrutinize client-side JavaScript. Are there large, monolithic functions? Could business logic be extracted from event handlers into standalone, easily testable functions?
|
||||
- **State Management**: How is client-side state managed? Are there risks of race conditions or inconsistent states from asynchronous operations (e.g., API calls)?
|
||||
|
||||
### 5. Observability and Maintainability
|
||||
- **Logging**: Are new logic paths and error cases instrumented with sufficient logging to be debuggable in production?
|
||||
- **Configuration**: Are new configurable values (thresholds, timeouts) identified for future inclusion in the customizer, as per project rules?
|
||||
- **Clarity**: Is the code clear, readable, and well-documented where complexity is unavoidable?
|
||||
|
||||
### 6. API and Data Integrity
|
||||
- **API Response Shape**: If the PR adds a UI feature that consumes an API, is there evidence the author verified the actual API response?
|
||||
- **Firmware as Source of Truth**: For any changes related to the MeshCore protocol, has the author referenced the `firmware/` source? Challenge any "magic numbers" or assumptions about packet structure.
|
||||
|
||||
## Review Process
|
||||
|
||||
1. **State Your Role**: Begin your review by announcing your function: "As the MeshCore PR Reviewer, I have analyzed this pull request based on the project's architectural guidelines and best practices."
|
||||
2. **Provide a Summary**: Give a high-level summary of your findings (e.g., "This PR looks solid but needs additions to testing," or "I have several concerns regarding performance and frontend testability.").
|
||||
3. **Detailed Feedback**: Use a bulleted list to present specific, actionable feedback, referencing file paths and line numbers. For each point, cite the relevant principle or project rule (e.g., "Missing Test Coverage (Rule #1)", "UI Logic Purity (Focus Area #4)").
|
||||
4. **End with a Clear Approval Status**: Conclude with a clear statement of "Approved" (with minor optional suggestions), "Changes Requested," or "Rejected" (for significant violations).
|
||||
---
|
||||
name: "MeshCore PR Reviewer"
|
||||
description: "A specialized agent for reviewing pull requests in the meshcore-analyzer repository. It focuses on SOLID, DRY, testing, Go best practices, frontend testability, observability, and performance to prevent regressions and maintain high code quality."
|
||||
model: "gpt-5.3-codex"
|
||||
tools: ["githubread", "add_issue_comment"]
|
||||
---
|
||||
|
||||
# MeshCore PR Reviewer Agent
|
||||
|
||||
You are an expert software engineer specializing in Go and JavaScript-heavy network analysis tools. Your primary role is to act as a meticulous pull request reviewer for the `Kpa-clawbot/meshcore-analyzer` repository. You are deeply familiar with its architecture, as outlined in `AGENTS.md`, and you enforce its rules rigorously.
|
||||
|
||||
Your reviews are thorough, constructive, and aimed at maintaining the highest standards of code quality, performance, and stability on both the backend and frontend.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Context is King**: Before any review, consult the `AGENTS.md` file in the `Kpa-clawbot/meshcore-analyzer` repository to ground your feedback in the project's established architecture and rules.
|
||||
2. **Enforce the Rules**: Your primary directive is to ensure every rule in `AGENTS.md` is followed. Call out any deviation.
|
||||
3. **Go & JS Best Practices**: Apply your deep knowledge of Go and modern JavaScript idioms. Pay close attention to concurrency, error handling, performance, and state management, especially as they relate to a real-time data processing application.
|
||||
4. **Constructive and Educational**: Your feedback should not only identify issues but also explain *why* they are issues and suggest idiomatic solutions. Your goal is to mentor and elevate the codebase and its contributors.
|
||||
5. **Be a Guardian**: Protect the project from regressions, performance degradation, and architectural drift.
|
||||
|
||||
## Review Focus Areas
|
||||
|
||||
You will pay special attention to the following areas during your review:
|
||||
|
||||
### 1. Architectural Adherence & Design Principles
|
||||
- **SOLID & DRY**: Does the change adhere to SOLID principles? Is there duplicated logic that could be refactored? Does it respect the existing separation of concerns?
|
||||
- **Project Architecture**: Does the PR respect the single Node.js server + static frontend architecture? Are changes in the right place?
|
||||
|
||||
### 2. Testing and Validation
|
||||
- **No commit without tests**: Is the backend logic change covered by unit tests? Is `test-packet-filter.js` or `test-aging.js` updated if necessary?
|
||||
- **Browser Validation**: Has the contributor confirmed the change works in a browser? Is there a screenshot for visual changes?
|
||||
- **Cache Busters**: If any `public/` assets (`.js`, `.css`) were modified, has the cache buster in `public/index.html` been bumped in the *same commit*? This is critical.
|
||||
|
||||
### 3. Go-Specific Concerns
|
||||
- **Concurrency**: Are goroutines used safely? Are there potential race conditions? Is synchronization used correctly?
|
||||
- **Error Handling**: Is error handling explicit and clear? Are errors wrapped with context where appropriate?
|
||||
- **Performance**: Are there inefficient loops or memory allocation patterns? Scrutinize any new data processing logic.
|
||||
- **Go Idioms**: Does the code follow standard Go idioms and formatting (`gofmt`)?
|
||||
|
||||
### 4. Frontend and UI Testability
|
||||
- **Acknowledge Complexity**: Does the PR introduce complex client-side logic? Recognize that browser-based functionality is difficult to unit test.
|
||||
- **Promote Testability**: Challenge the contributor to refactor UI code to improve testability. Are data manipulation, state management, and rendering logic separated? Logic should be in pure, testable functions, not tangled in DOM manipulation code.
|
||||
- **UI Logic Purity**: Scrutinize client-side JavaScript. Are there large, monolithic functions? Could business logic be extracted from event handlers into standalone, easily testable functions?
|
||||
- **State Management**: How is client-side state managed? Are there risks of race conditions or inconsistent states from asynchronous operations (e.g., API calls)?
|
||||
|
||||
### 5. Observability and Maintainability
|
||||
- **Logging**: Are new logic paths and error cases instrumented with sufficient logging to be debuggable in production?
|
||||
- **Configuration**: Are new configurable values (thresholds, timeouts) identified for future inclusion in the customizer, as per project rules?
|
||||
- **Clarity**: Is the code clear, readable, and well-documented where complexity is unavoidable?
|
||||
|
||||
### 6. API and Data Integrity
|
||||
- **API Response Shape**: If the PR adds a UI feature that consumes an API, is there evidence the author verified the actual API response?
|
||||
- **Firmware as Source of Truth**: For any changes related to the MeshCore protocol, has the author referenced the `firmware/` source? Challenge any "magic numbers" or assumptions about packet structure.
|
||||
|
||||
## Review Process
|
||||
|
||||
1. **State Your Role**: Begin your review by announcing your function: "As the MeshCore PR Reviewer, I have analyzed this pull request based on the project's architectural guidelines and best practices."
|
||||
2. **Provide a Summary**: Give a high-level summary of your findings (e.g., "This PR looks solid but needs additions to testing," or "I have several concerns regarding performance and frontend testability.").
|
||||
3. **Detailed Feedback**: Use a bulleted list to present specific, actionable feedback, referencing file paths and line numbers. For each point, cite the relevant principle or project rule (e.g., "Missing Test Coverage (Rule #1)", "UI Logic Purity (Focus Area #4)").
|
||||
4. **End with a Clear Approval Status**: Conclude with a clear statement of "Approved" (with minor optional suggestions), "Changes Requested," or "Rejected" (for significant violations).
|
||||
|
||||
+378
-378
@@ -1,378 +1,378 @@
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
STAGING_COMPOSE_FILE: docker-compose.staging.yml
|
||||
STAGING_SERVICE: staging-go
|
||||
STAGING_CONTAINER: corescope-staging-go
|
||||
|
||||
# Pipeline (sequential, fail-fast):
|
||||
# go-test → e2e-test → build → deploy → publish
|
||||
# PRs stop after build. Master continues to deploy + publish.
|
||||
|
||||
jobs:
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 1. Go Build & Test
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
go-test:
|
||||
name: "✅ Go Build & Test"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Clean Go module cache
|
||||
run: rm -rf ~/go/pkg/mod 2>/dev/null || true
|
||||
|
||||
- name: Set up Go 1.22
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache-dependency-path: |
|
||||
cmd/server/go.sum
|
||||
cmd/ingestor/go.sum
|
||||
|
||||
- name: Build and test Go server (with coverage)
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
cd cmd/server
|
||||
go build .
|
||||
go test -coverprofile=server-coverage.out ./... 2>&1 | tee server-test.log
|
||||
echo "--- Go Server Coverage ---"
|
||||
go tool cover -func=server-coverage.out | tail -1
|
||||
|
||||
- name: Build and test Go ingestor (with coverage)
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
cd cmd/ingestor
|
||||
go build .
|
||||
go test -coverprofile=ingestor-coverage.out ./... 2>&1 | tee ingestor-test.log
|
||||
echo "--- Go Ingestor Coverage ---"
|
||||
go tool cover -func=ingestor-coverage.out | tail -1
|
||||
|
||||
- name: Verify proto syntax
|
||||
run: |
|
||||
set -e
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y protobuf-compiler
|
||||
for proto in proto/*.proto; do
|
||||
echo " ✓ $(basename "$proto")"
|
||||
protoc --proto_path=proto --descriptor_set_out=/dev/null "$proto"
|
||||
done
|
||||
echo "✅ All .proto files are syntactically valid"
|
||||
|
||||
- name: Generate Go coverage badges
|
||||
if: success()
|
||||
run: |
|
||||
mkdir -p .badges
|
||||
|
||||
SERVER_COV="0"
|
||||
if [ -f cmd/server/server-coverage.out ]; then
|
||||
SERVER_COV=$(cd cmd/server && go tool cover -func=server-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)')
|
||||
fi
|
||||
SERVER_COLOR="red"
|
||||
if [ "$(echo "$SERVER_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then SERVER_COLOR="green"
|
||||
elif [ "$(echo "$SERVER_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then SERVER_COLOR="yellow"; fi
|
||||
echo "{\"schemaVersion\":1,\"label\":\"go server coverage\",\"message\":\"${SERVER_COV}%\",\"color\":\"${SERVER_COLOR}\"}" > .badges/go-server-coverage.json
|
||||
|
||||
INGESTOR_COV="0"
|
||||
if [ -f cmd/ingestor/ingestor-coverage.out ]; then
|
||||
INGESTOR_COV=$(cd cmd/ingestor && go tool cover -func=ingestor-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)')
|
||||
fi
|
||||
INGESTOR_COLOR="red"
|
||||
if [ "$(echo "$INGESTOR_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then INGESTOR_COLOR="green"
|
||||
elif [ "$(echo "$INGESTOR_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then INGESTOR_COLOR="yellow"; fi
|
||||
echo "{\"schemaVersion\":1,\"label\":\"go ingestor coverage\",\"message\":\"${INGESTOR_COV}%\",\"color\":\"${INGESTOR_COLOR}\"}" > .badges/go-ingestor-coverage.json
|
||||
|
||||
echo "## Go Coverage" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Module | Coverage |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|--------|----------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Server | ${SERVER_COV}% |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Ingestor | ${INGESTOR_COV}% |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload Go coverage badges
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: go-badges
|
||||
path: .badges/go-*.json
|
||||
retention-days: 1
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 2. Playwright E2E Tests (against Go server with fixture DB)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
e2e-test:
|
||||
name: "🎭 Playwright E2E Tests"
|
||||
needs: [go-test]
|
||||
runs-on: [self-hosted, Linux]
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Clean Go module cache
|
||||
run: rm -rf ~/go/pkg/mod 2>/dev/null || true
|
||||
|
||||
- name: Set up Go 1.22
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache-dependency-path: cmd/server/go.sum
|
||||
|
||||
- name: Build Go server
|
||||
run: |
|
||||
cd cmd/server
|
||||
go build -o ../../corescope-server .
|
||||
echo "Go server built successfully"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci --production=false
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: |
|
||||
npx playwright install chromium 2>/dev/null || true
|
||||
npx playwright install-deps chromium 2>/dev/null || true
|
||||
|
||||
- name: Instrument frontend JS for coverage
|
||||
run: sh scripts/instrument-frontend.sh
|
||||
|
||||
- name: Start Go server with fixture DB
|
||||
run: |
|
||||
fuser -k 13581/tcp 2>/dev/null || true
|
||||
sleep 1
|
||||
./corescope-server -port 13581 -db test-fixtures/e2e-fixture.db -public public-instrumented &
|
||||
echo $! > .server.pid
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:13581/api/stats > /dev/null 2>&1; then
|
||||
echo "Server ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "Server failed to start within 30s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run Playwright E2E tests (fail-fast)
|
||||
run: |
|
||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
|
||||
|
||||
- name: Collect frontend coverage (parallel)
|
||||
if: success() && github.event_name == 'push'
|
||||
run: |
|
||||
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt || true
|
||||
|
||||
- name: Generate frontend coverage badges
|
||||
if: success()
|
||||
run: |
|
||||
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1 || echo "0")
|
||||
|
||||
mkdir -p .badges
|
||||
if [ -f .nyc_output/frontend-coverage.json ] || [ -f .nyc_output/e2e-coverage.json ]; then
|
||||
npx nyc report --reporter=text-summary --reporter=text 2>&1 | tee fe-report.txt
|
||||
FE_COVERAGE=$(grep 'Statements' fe-report.txt | head -1 | grep -oP '[\d.]+(?=%)' || echo "0")
|
||||
FE_COVERAGE=${FE_COVERAGE:-0}
|
||||
FE_COLOR="red"
|
||||
[ "$(echo "$FE_COVERAGE > 50" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="yellow"
|
||||
[ "$(echo "$FE_COVERAGE > 80" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="brightgreen"
|
||||
echo "{\"schemaVersion\":1,\"label\":\"frontend coverage\",\"message\":\"${FE_COVERAGE}%\",\"color\":\"${FE_COLOR}\"}" > .badges/frontend-coverage.json
|
||||
echo "## Frontend: ${FE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "{\"schemaVersion\":1,\"label\":\"e2e tests\",\"message\":\"${E2E_PASS:-0} passed\",\"color\":\"brightgreen\"}" > .badges/e2e-tests.json
|
||||
|
||||
- name: Stop test server
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f .server.pid ]; then
|
||||
kill $(cat .server.pid) 2>/dev/null || true
|
||||
rm -f .server.pid
|
||||
fi
|
||||
|
||||
- name: Upload E2E badges
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-badges
|
||||
path: .badges/
|
||||
retention-days: 1
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 3. Build Docker Image
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: "🏗️ Build Docker Image"
|
||||
needs: [e2e-test]
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Build Go Docker image
|
||||
run: |
|
||||
echo "${GITHUB_SHA::7}" > .git-commit
|
||||
APP_VERSION=$(node -p "require('./package.json').version") \
|
||||
GIT_COMMIT="${GITHUB_SHA::7}" \
|
||||
APP_VERSION=$(grep -oP 'APP_VERSION:-\K[^}]+' docker-compose.yml | head -1 || echo "3.0.0")
|
||||
GIT_COMMIT=$(git rev-parse --short HEAD)
|
||||
BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
export APP_VERSION GIT_COMMIT BUILD_TIME
|
||||
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging build "$STAGING_SERVICE"
|
||||
echo "Built Go staging image ✅"
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 4. Deploy Staging (master only)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
name: "🚀 Deploy Staging"
|
||||
if: github.event_name == 'push'
|
||||
needs: [build]
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Deploy staging
|
||||
run: |
|
||||
# Stop old container and release memory
|
||||
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging down --timeout 30 2>/dev/null || true
|
||||
|
||||
# Wait for container to be fully gone and OS to reclaim memory (3GB limit)
|
||||
for i in $(seq 1 15); do
|
||||
if ! docker ps -a --format '{{.Names}}' | grep -q 'corescope-staging-go'; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
sleep 5 # extra pause for OS memory reclaim
|
||||
|
||||
# Ensure staging data dir exists (config.json lives here, no separate file mount)
|
||||
STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
|
||||
mkdir -p "$STAGING_DATA"
|
||||
|
||||
# If no config exists, copy the example (CI doesn't have a real prod config)
|
||||
if [ ! -f "$STAGING_DATA/config.json" ]; then
|
||||
echo "Staging config missing — copying config.example.json"
|
||||
cp config.example.json "$STAGING_DATA/config.json" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging up -d staging-go
|
||||
|
||||
- name: Healthcheck staging container
|
||||
run: |
|
||||
for i in $(seq 1 120); do
|
||||
HEALTH=$(docker inspect corescope-staging-go --format '{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
||||
if [ "$HEALTH" = "healthy" ]; then
|
||||
echo "Staging healthy after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 120 ]; then
|
||||
echo "Staging failed health check after 120s"
|
||||
docker logs corescope-staging-go --tail 50
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Smoke test staging API
|
||||
run: |
|
||||
if curl -sf http://localhost:82/api/stats | grep -q engine; then
|
||||
echo "Staging verified — engine field present ✅"
|
||||
else
|
||||
echo "Staging /api/stats did not return engine field"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 5. Publish Badges & Summary (master only)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
publish:
|
||||
name: "📝 Publish Badges & Summary"
|
||||
if: github.event_name == 'push'
|
||||
needs: [deploy]
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Download Go coverage badges
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: go-badges
|
||||
path: .badges/
|
||||
|
||||
- name: Download E2E badges
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: e2e-badges
|
||||
path: .badges/
|
||||
|
||||
- name: Publish coverage badges to repo
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.BADGE_PUSH_TOKEN }}
|
||||
run: |
|
||||
# GITHUB_TOKEN cannot push to protected branches (required status checks).
|
||||
# Use admin PAT (BADGE_PUSH_TOKEN) via GitHub Contents API instead.
|
||||
for badge in .badges/*.json; do
|
||||
FILENAME=$(basename "$badge")
|
||||
FILEPATH=".badges/$FILENAME"
|
||||
CONTENT=$(base64 -w0 "$badge")
|
||||
CURRENT_SHA=$(gh api "repos/${{ github.repository }}/contents/$FILEPATH" --jq '.sha' 2>/dev/null || echo "")
|
||||
if [ -n "$CURRENT_SHA" ]; then
|
||||
gh api "repos/${{ github.repository }}/contents/$FILEPATH" \
|
||||
-X PUT \
|
||||
-f message="ci: update $FILENAME [skip ci]" \
|
||||
-f content="$CONTENT" \
|
||||
-f sha="$CURRENT_SHA" \
|
||||
-f branch="master" \
|
||||
--silent 2>&1 || echo "Failed to update $FILENAME"
|
||||
else
|
||||
gh api "repos/${{ github.repository }}/contents/$FILEPATH" \
|
||||
-X PUT \
|
||||
-f message="ci: update $FILENAME [skip ci]" \
|
||||
-f content="$CONTENT" \
|
||||
-f branch="master" \
|
||||
--silent 2>&1 || echo "Failed to create $FILENAME"
|
||||
fi
|
||||
done
|
||||
echo "Badge publish complete"
|
||||
|
||||
- name: Post deployment summary
|
||||
run: |
|
||||
echo "## Staging Deployed ✓" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Commit:** \`$(git rev-parse --short HEAD)\` — $(git log -1 --format=%s)" >> $GITHUB_STEP_SUMMARY
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
STAGING_COMPOSE_FILE: docker-compose.staging.yml
|
||||
STAGING_SERVICE: staging-go
|
||||
STAGING_CONTAINER: corescope-staging-go
|
||||
|
||||
# Pipeline (sequential, fail-fast):
|
||||
# go-test → e2e-test → build → deploy → publish
|
||||
# PRs stop after build. Master continues to deploy + publish.
|
||||
|
||||
jobs:
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 1. Go Build & Test
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
go-test:
|
||||
name: "✅ Go Build & Test"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Clean Go module cache
|
||||
run: rm -rf ~/go/pkg/mod 2>/dev/null || true
|
||||
|
||||
- name: Set up Go 1.22
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache-dependency-path: |
|
||||
cmd/server/go.sum
|
||||
cmd/ingestor/go.sum
|
||||
|
||||
- name: Build and test Go server (with coverage)
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
cd cmd/server
|
||||
go build .
|
||||
go test -coverprofile=server-coverage.out ./... 2>&1 | tee server-test.log
|
||||
echo "--- Go Server Coverage ---"
|
||||
go tool cover -func=server-coverage.out | tail -1
|
||||
|
||||
- name: Build and test Go ingestor (with coverage)
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
cd cmd/ingestor
|
||||
go build .
|
||||
go test -coverprofile=ingestor-coverage.out ./... 2>&1 | tee ingestor-test.log
|
||||
echo "--- Go Ingestor Coverage ---"
|
||||
go tool cover -func=ingestor-coverage.out | tail -1
|
||||
|
||||
- name: Verify proto syntax
|
||||
run: |
|
||||
set -e
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y protobuf-compiler
|
||||
for proto in proto/*.proto; do
|
||||
echo " ✓ $(basename "$proto")"
|
||||
protoc --proto_path=proto --descriptor_set_out=/dev/null "$proto"
|
||||
done
|
||||
echo "✅ All .proto files are syntactically valid"
|
||||
|
||||
- name: Generate Go coverage badges
|
||||
if: success()
|
||||
run: |
|
||||
mkdir -p .badges
|
||||
|
||||
SERVER_COV="0"
|
||||
if [ -f cmd/server/server-coverage.out ]; then
|
||||
SERVER_COV=$(cd cmd/server && go tool cover -func=server-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)')
|
||||
fi
|
||||
SERVER_COLOR="red"
|
||||
if [ "$(echo "$SERVER_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then SERVER_COLOR="green"
|
||||
elif [ "$(echo "$SERVER_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then SERVER_COLOR="yellow"; fi
|
||||
echo "{\"schemaVersion\":1,\"label\":\"go server coverage\",\"message\":\"${SERVER_COV}%\",\"color\":\"${SERVER_COLOR}\"}" > .badges/go-server-coverage.json
|
||||
|
||||
INGESTOR_COV="0"
|
||||
if [ -f cmd/ingestor/ingestor-coverage.out ]; then
|
||||
INGESTOR_COV=$(cd cmd/ingestor && go tool cover -func=ingestor-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)')
|
||||
fi
|
||||
INGESTOR_COLOR="red"
|
||||
if [ "$(echo "$INGESTOR_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then INGESTOR_COLOR="green"
|
||||
elif [ "$(echo "$INGESTOR_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then INGESTOR_COLOR="yellow"; fi
|
||||
echo "{\"schemaVersion\":1,\"label\":\"go ingestor coverage\",\"message\":\"${INGESTOR_COV}%\",\"color\":\"${INGESTOR_COLOR}\"}" > .badges/go-ingestor-coverage.json
|
||||
|
||||
echo "## Go Coverage" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Module | Coverage |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|--------|----------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Server | ${SERVER_COV}% |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Ingestor | ${INGESTOR_COV}% |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload Go coverage badges
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: go-badges
|
||||
path: .badges/go-*.json
|
||||
retention-days: 1
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 2. Playwright E2E Tests (against Go server with fixture DB)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
e2e-test:
|
||||
name: "🎭 Playwright E2E Tests"
|
||||
needs: [go-test]
|
||||
runs-on: [self-hosted, Linux]
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Clean Go module cache
|
||||
run: rm -rf ~/go/pkg/mod 2>/dev/null || true
|
||||
|
||||
- name: Set up Go 1.22
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache-dependency-path: cmd/server/go.sum
|
||||
|
||||
- name: Build Go server
|
||||
run: |
|
||||
cd cmd/server
|
||||
go build -o ../../corescope-server .
|
||||
echo "Go server built successfully"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci --production=false
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: |
|
||||
npx playwright install chromium 2>/dev/null || true
|
||||
npx playwright install-deps chromium 2>/dev/null || true
|
||||
|
||||
- name: Instrument frontend JS for coverage
|
||||
run: sh scripts/instrument-frontend.sh
|
||||
|
||||
- name: Start Go server with fixture DB
|
||||
run: |
|
||||
fuser -k 13581/tcp 2>/dev/null || true
|
||||
sleep 1
|
||||
./corescope-server -port 13581 -db test-fixtures/e2e-fixture.db -public public-instrumented &
|
||||
echo $! > .server.pid
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:13581/api/stats > /dev/null 2>&1; then
|
||||
echo "Server ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "Server failed to start within 30s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run Playwright E2E tests (fail-fast)
|
||||
run: |
|
||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
|
||||
|
||||
- name: Collect frontend coverage (parallel)
|
||||
if: success() && github.event_name == 'push'
|
||||
run: |
|
||||
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt || true
|
||||
|
||||
- name: Generate frontend coverage badges
|
||||
if: success()
|
||||
run: |
|
||||
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1 || echo "0")
|
||||
|
||||
mkdir -p .badges
|
||||
if [ -f .nyc_output/frontend-coverage.json ] || [ -f .nyc_output/e2e-coverage.json ]; then
|
||||
npx nyc report --reporter=text-summary --reporter=text 2>&1 | tee fe-report.txt
|
||||
FE_COVERAGE=$(grep 'Statements' fe-report.txt | head -1 | grep -oP '[\d.]+(?=%)' || echo "0")
|
||||
FE_COVERAGE=${FE_COVERAGE:-0}
|
||||
FE_COLOR="red"
|
||||
[ "$(echo "$FE_COVERAGE > 50" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="yellow"
|
||||
[ "$(echo "$FE_COVERAGE > 80" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="brightgreen"
|
||||
echo "{\"schemaVersion\":1,\"label\":\"frontend coverage\",\"message\":\"${FE_COVERAGE}%\",\"color\":\"${FE_COLOR}\"}" > .badges/frontend-coverage.json
|
||||
echo "## Frontend: ${FE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "{\"schemaVersion\":1,\"label\":\"e2e tests\",\"message\":\"${E2E_PASS:-0} passed\",\"color\":\"brightgreen\"}" > .badges/e2e-tests.json
|
||||
|
||||
- name: Stop test server
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f .server.pid ]; then
|
||||
kill $(cat .server.pid) 2>/dev/null || true
|
||||
rm -f .server.pid
|
||||
fi
|
||||
|
||||
- name: Upload E2E badges
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-badges
|
||||
path: .badges/
|
||||
retention-days: 1
|
||||
if-no-files-found: ignore
|
||||
include-hidden-files: true
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 3. Build Docker Image
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: "🏗️ Build Docker Image"
|
||||
needs: [e2e-test]
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Build Go Docker image
|
||||
run: |
|
||||
echo "${GITHUB_SHA::7}" > .git-commit
|
||||
APP_VERSION=$(node -p "require('./package.json').version") \
|
||||
GIT_COMMIT="${GITHUB_SHA::7}" \
|
||||
APP_VERSION=$(grep -oP 'APP_VERSION:-\K[^}]+' docker-compose.yml | head -1 || echo "3.0.0")
|
||||
GIT_COMMIT=$(git rev-parse --short HEAD)
|
||||
BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
export APP_VERSION GIT_COMMIT BUILD_TIME
|
||||
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging build "$STAGING_SERVICE"
|
||||
echo "Built Go staging image ✅"
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 4. Deploy Staging (master only)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
name: "🚀 Deploy Staging"
|
||||
if: github.event_name == 'push'
|
||||
needs: [build]
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Deploy staging
|
||||
run: |
|
||||
# Stop old container and release memory
|
||||
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging down --timeout 30 2>/dev/null || true
|
||||
|
||||
# Wait for container to be fully gone and OS to reclaim memory (3GB limit)
|
||||
for i in $(seq 1 15); do
|
||||
if ! docker ps -a --format '{{.Names}}' | grep -q 'corescope-staging-go'; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
sleep 5 # extra pause for OS memory reclaim
|
||||
|
||||
# Ensure staging data dir exists (config.json lives here, no separate file mount)
|
||||
STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
|
||||
mkdir -p "$STAGING_DATA"
|
||||
|
||||
# If no config exists, copy the example (CI doesn't have a real prod config)
|
||||
if [ ! -f "$STAGING_DATA/config.json" ]; then
|
||||
echo "Staging config missing — copying config.example.json"
|
||||
cp config.example.json "$STAGING_DATA/config.json" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging up -d staging-go
|
||||
|
||||
- name: Healthcheck staging container
|
||||
run: |
|
||||
for i in $(seq 1 120); do
|
||||
HEALTH=$(docker inspect corescope-staging-go --format '{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
||||
if [ "$HEALTH" = "healthy" ]; then
|
||||
echo "Staging healthy after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 120 ]; then
|
||||
echo "Staging failed health check after 120s"
|
||||
docker logs corescope-staging-go --tail 50
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Smoke test staging API
|
||||
run: |
|
||||
if curl -sf http://localhost:82/api/stats | grep -q engine; then
|
||||
echo "Staging verified — engine field present ✅"
|
||||
else
|
||||
echo "Staging /api/stats did not return engine field"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 5. Publish Badges & Summary (master only)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
publish:
|
||||
name: "📝 Publish Badges & Summary"
|
||||
if: github.event_name == 'push'
|
||||
needs: [deploy]
|
||||
runs-on: [self-hosted, Linux]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Download Go coverage badges
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: go-badges
|
||||
path: .badges/
|
||||
|
||||
- name: Download E2E badges
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: e2e-badges
|
||||
path: .badges/
|
||||
|
||||
- name: Publish coverage badges to repo
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.BADGE_PUSH_TOKEN }}
|
||||
run: |
|
||||
# GITHUB_TOKEN cannot push to protected branches (required status checks).
|
||||
# Use admin PAT (BADGE_PUSH_TOKEN) via GitHub Contents API instead.
|
||||
for badge in .badges/*.json; do
|
||||
FILENAME=$(basename "$badge")
|
||||
FILEPATH=".badges/$FILENAME"
|
||||
CONTENT=$(base64 -w0 "$badge")
|
||||
CURRENT_SHA=$(gh api "repos/${{ github.repository }}/contents/$FILEPATH" --jq '.sha' 2>/dev/null || echo "")
|
||||
if [ -n "$CURRENT_SHA" ]; then
|
||||
gh api "repos/${{ github.repository }}/contents/$FILEPATH" \
|
||||
-X PUT \
|
||||
-f message="ci: update $FILENAME [skip ci]" \
|
||||
-f content="$CONTENT" \
|
||||
-f sha="$CURRENT_SHA" \
|
||||
-f branch="master" \
|
||||
--silent 2>&1 || echo "Failed to update $FILENAME"
|
||||
else
|
||||
gh api "repos/${{ github.repository }}/contents/$FILEPATH" \
|
||||
-X PUT \
|
||||
-f message="ci: update $FILENAME [skip ci]" \
|
||||
-f content="$CONTENT" \
|
||||
-f branch="master" \
|
||||
--silent 2>&1 || echo "Failed to create $FILENAME"
|
||||
fi
|
||||
done
|
||||
echo "Badge publish complete"
|
||||
|
||||
- name: Post deployment summary
|
||||
run: |
|
||||
echo "## Staging Deployed ✓" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Commit:** \`$(git rev-parse --short HEAD)\` — $(git log -1 --format=%s)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -1,171 +1,171 @@
|
||||
name: Squad Heartbeat (Ralph)
|
||||
# ⚠️ SYNC: This workflow is maintained in 4 locations. Changes must be applied to all:
|
||||
# - templates/workflows/squad-heartbeat.yml (source template)
|
||||
# - packages/squad-cli/templates/workflows/squad-heartbeat.yml (CLI package)
|
||||
# - .squad/templates/workflows/squad-heartbeat.yml (installed template)
|
||||
# - .github/workflows/squad-heartbeat.yml (active workflow)
|
||||
# Run 'squad upgrade' to sync installed copies from source templates.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 30 minutes — adjust via cron expression as needed
|
||||
- cron: '*/30 * * * *'
|
||||
|
||||
# React to completed work or new squad work
|
||||
issues:
|
||||
types: [closed, labeled]
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
# Manual trigger
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
heartbeat:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check triage script
|
||||
id: check-script
|
||||
run: |
|
||||
if [ -f ".squad/templates/ralph-triage.js" ]; then
|
||||
echo "has_script=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_script=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ ralph-triage.js not found — run 'squad upgrade' to install"
|
||||
fi
|
||||
|
||||
- name: Ralph — Smart triage
|
||||
if: steps.check-script.outputs.has_script == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
node .squad/templates/ralph-triage.js \
|
||||
--squad-dir .squad \
|
||||
--output triage-results.json
|
||||
|
||||
- name: Ralph — Apply triage decisions
|
||||
if: steps.check-script.outputs.has_script == 'true' && hashFiles('triage-results.json') != ''
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = 'triage-results.json';
|
||||
if (!fs.existsSync(path)) {
|
||||
core.info('No triage results — board is clear');
|
||||
return;
|
||||
}
|
||||
|
||||
const results = JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||
if (results.length === 0) {
|
||||
core.info('📋 Board is clear — Ralph found no untriaged issues');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const decision of results) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: decision.issueNumber,
|
||||
labels: [decision.label]
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: decision.issueNumber,
|
||||
body: [
|
||||
'### 🔄 Ralph — Auto-Triage',
|
||||
'',
|
||||
`**Assigned to:** ${decision.assignTo}`,
|
||||
`**Reason:** ${decision.reason}`,
|
||||
`**Source:** ${decision.source}`,
|
||||
'',
|
||||
'> Ralph auto-triaged this issue using routing rules.',
|
||||
'> To reassign, swap the `squad:*` label.'
|
||||
].join('\n')
|
||||
});
|
||||
|
||||
core.info(`Triaged #${decision.issueNumber} → ${decision.assignTo} (${decision.source})`);
|
||||
} catch (e) {
|
||||
core.warning(`Failed to triage #${decision.issueNumber}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`🔄 Ralph triaged ${results.length} issue(s)`);
|
||||
|
||||
# Copilot auto-assign step (uses PAT if available)
|
||||
- name: Ralph — Assign @copilot issues
|
||||
if: success()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
let teamFile = '.squad/team.md';
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
teamFile = '.ai-team/team.md';
|
||||
}
|
||||
if (!fs.existsSync(teamFile)) return;
|
||||
|
||||
const content = fs.readFileSync(teamFile, 'utf8');
|
||||
|
||||
// Check if @copilot is on the team with auto-assign
|
||||
const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot');
|
||||
const autoAssign = content.includes('<!-- copilot-auto-assign: true -->');
|
||||
if (!hasCopilot || !autoAssign) return;
|
||||
|
||||
// Find issues labeled squad:copilot with no assignee
|
||||
try {
|
||||
const { data: copilotIssues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'squad:copilot',
|
||||
state: 'open',
|
||||
per_page: 5
|
||||
});
|
||||
|
||||
const unassigned = copilotIssues.filter(i =>
|
||||
!i.assignees || i.assignees.length === 0
|
||||
);
|
||||
|
||||
if (unassigned.length === 0) {
|
||||
core.info('No unassigned squad:copilot issues');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get repo default branch
|
||||
const { data: repoData } = await github.rest.repos.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
|
||||
for (const issue of unassigned) {
|
||||
try {
|
||||
await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
assignees: ['copilot-swe-agent[bot]'],
|
||||
agent_assignment: {
|
||||
target_repo: `${context.repo.owner}/${context.repo.repo}`,
|
||||
base_branch: repoData.default_branch,
|
||||
custom_instructions: `Read .squad/team.md (or .ai-team/team.md) for team context and .squad/routing.md (or .ai-team/routing.md) for routing rules.`
|
||||
}
|
||||
});
|
||||
core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`);
|
||||
} catch (e) {
|
||||
core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
core.info(`No squad:copilot label found or error: ${e.message}`);
|
||||
}
|
||||
name: Squad Heartbeat (Ralph)
|
||||
# ⚠️ SYNC: This workflow is maintained in 4 locations. Changes must be applied to all:
|
||||
# - templates/workflows/squad-heartbeat.yml (source template)
|
||||
# - packages/squad-cli/templates/workflows/squad-heartbeat.yml (CLI package)
|
||||
# - .squad/templates/workflows/squad-heartbeat.yml (installed template)
|
||||
# - .github/workflows/squad-heartbeat.yml (active workflow)
|
||||
# Run 'squad upgrade' to sync installed copies from source templates.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 30 minutes — adjust via cron expression as needed
|
||||
- cron: '*/30 * * * *'
|
||||
|
||||
# React to completed work or new squad work
|
||||
issues:
|
||||
types: [closed, labeled]
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
# Manual trigger
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
heartbeat:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check triage script
|
||||
id: check-script
|
||||
run: |
|
||||
if [ -f ".squad/templates/ralph-triage.js" ]; then
|
||||
echo "has_script=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_script=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ ralph-triage.js not found — run 'squad upgrade' to install"
|
||||
fi
|
||||
|
||||
- name: Ralph — Smart triage
|
||||
if: steps.check-script.outputs.has_script == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
node .squad/templates/ralph-triage.js \
|
||||
--squad-dir .squad \
|
||||
--output triage-results.json
|
||||
|
||||
- name: Ralph — Apply triage decisions
|
||||
if: steps.check-script.outputs.has_script == 'true' && hashFiles('triage-results.json') != ''
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = 'triage-results.json';
|
||||
if (!fs.existsSync(path)) {
|
||||
core.info('No triage results — board is clear');
|
||||
return;
|
||||
}
|
||||
|
||||
const results = JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||
if (results.length === 0) {
|
||||
core.info('📋 Board is clear — Ralph found no untriaged issues');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const decision of results) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: decision.issueNumber,
|
||||
labels: [decision.label]
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: decision.issueNumber,
|
||||
body: [
|
||||
'### 🔄 Ralph — Auto-Triage',
|
||||
'',
|
||||
`**Assigned to:** ${decision.assignTo}`,
|
||||
`**Reason:** ${decision.reason}`,
|
||||
`**Source:** ${decision.source}`,
|
||||
'',
|
||||
'> Ralph auto-triaged this issue using routing rules.',
|
||||
'> To reassign, swap the `squad:*` label.'
|
||||
].join('\n')
|
||||
});
|
||||
|
||||
core.info(`Triaged #${decision.issueNumber} → ${decision.assignTo} (${decision.source})`);
|
||||
} catch (e) {
|
||||
core.warning(`Failed to triage #${decision.issueNumber}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`🔄 Ralph triaged ${results.length} issue(s)`);
|
||||
|
||||
# Copilot auto-assign step (uses PAT if available)
|
||||
- name: Ralph — Assign @copilot issues
|
||||
if: success()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
let teamFile = '.squad/team.md';
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
teamFile = '.ai-team/team.md';
|
||||
}
|
||||
if (!fs.existsSync(teamFile)) return;
|
||||
|
||||
const content = fs.readFileSync(teamFile, 'utf8');
|
||||
|
||||
// Check if @copilot is on the team with auto-assign
|
||||
const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot');
|
||||
const autoAssign = content.includes('<!-- copilot-auto-assign: true -->');
|
||||
if (!hasCopilot || !autoAssign) return;
|
||||
|
||||
// Find issues labeled squad:copilot with no assignee
|
||||
try {
|
||||
const { data: copilotIssues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'squad:copilot',
|
||||
state: 'open',
|
||||
per_page: 5
|
||||
});
|
||||
|
||||
const unassigned = copilotIssues.filter(i =>
|
||||
!i.assignees || i.assignees.length === 0
|
||||
);
|
||||
|
||||
if (unassigned.length === 0) {
|
||||
core.info('No unassigned squad:copilot issues');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get repo default branch
|
||||
const { data: repoData } = await github.rest.repos.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
|
||||
for (const issue of unassigned) {
|
||||
try {
|
||||
await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
assignees: ['copilot-swe-agent[bot]'],
|
||||
agent_assignment: {
|
||||
target_repo: `${context.repo.owner}/${context.repo.repo}`,
|
||||
base_branch: repoData.default_branch,
|
||||
custom_instructions: `Read .squad/team.md (or .ai-team/team.md) for team context and .squad/routing.md (or .ai-team/routing.md) for routing rules.`
|
||||
}
|
||||
});
|
||||
core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`);
|
||||
} catch (e) {
|
||||
core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
core.info(`No squad:copilot label found or error: ${e.message}`);
|
||||
}
|
||||
|
||||
@@ -1,161 +1,161 @@
|
||||
name: Squad Issue Assign
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
assign-work:
|
||||
# Only trigger on squad:{member} labels (not the base "squad" label)
|
||||
if: startsWith(github.event.label.name, 'squad:')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Identify assigned member and trigger work
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const issue = context.payload.issue;
|
||||
const label = context.payload.label.name;
|
||||
|
||||
// Extract member name from label (e.g., "squad:ripley" → "ripley")
|
||||
const memberName = label.replace('squad:', '').toLowerCase();
|
||||
|
||||
// Read team roster — check .squad/ first, fall back to .ai-team/
|
||||
let teamFile = '.squad/team.md';
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
teamFile = '.ai-team/team.md';
|
||||
}
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
core.warning('No .squad/team.md or .ai-team/team.md found — cannot assign work');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(teamFile, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Check if this is a coding agent assignment
|
||||
const isCopilotAssignment = memberName === 'copilot';
|
||||
|
||||
let assignedMember = null;
|
||||
if (isCopilotAssignment) {
|
||||
assignedMember = { name: '@copilot', role: 'Coding Agent' };
|
||||
} else {
|
||||
let inMembersTable = false;
|
||||
for (const line of lines) {
|
||||
if (line.match(/^##\s+(Members|Team Roster)/i)) {
|
||||
inMembersTable = true;
|
||||
continue;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('## ')) {
|
||||
break;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
|
||||
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
||||
if (cells.length >= 2 && cells[0].toLowerCase() === memberName) {
|
||||
assignedMember = { name: cells[0], role: cells[1] };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!assignedMember) {
|
||||
core.warning(`No member found matching label "${label}"`);
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `⚠️ No squad member found matching label \`${label}\`. Check \`.squad/team.md\` (or \`.ai-team/team.md\`) for valid member names.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Post assignment acknowledgment
|
||||
let comment;
|
||||
if (isCopilotAssignment) {
|
||||
comment = [
|
||||
`### 🤖 Routed to @copilot (Coding Agent)`,
|
||||
'',
|
||||
`**Issue:** #${issue.number} — ${issue.title}`,
|
||||
'',
|
||||
`@copilot has been assigned and will pick this up automatically.`,
|
||||
'',
|
||||
`> The coding agent will create a \`copilot/*\` branch and open a draft PR.`,
|
||||
`> Review the PR as you would any team member's work.`,
|
||||
].join('\n');
|
||||
} else {
|
||||
comment = [
|
||||
`### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`,
|
||||
'',
|
||||
`**Issue:** #${issue.number} — ${issue.title}`,
|
||||
'',
|
||||
`${assignedMember.name} will pick this up in the next Copilot session.`,
|
||||
'',
|
||||
`> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`,
|
||||
`> Otherwise, start a Copilot session and say:`,
|
||||
`> \`${assignedMember.name}, work on issue #${issue.number}\``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`);
|
||||
|
||||
# Separate step: assign @copilot using PAT (required for coding agent)
|
||||
- name: Assign @copilot coding agent
|
||||
if: github.event.label.name == 'squad:copilot'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const issue_number = context.payload.issue.number;
|
||||
|
||||
// Get the default branch name (main, master, etc.)
|
||||
const { data: repoData } = await github.rest.repos.get({ owner, repo });
|
||||
const baseBranch = repoData.default_branch;
|
||||
|
||||
try {
|
||||
await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
assignees: ['copilot-swe-agent[bot]'],
|
||||
agent_assignment: {
|
||||
target_repo: `${owner}/${repo}`,
|
||||
base_branch: baseBranch,
|
||||
custom_instructions: '',
|
||||
custom_agent: '',
|
||||
model: ''
|
||||
},
|
||||
headers: {
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
}
|
||||
});
|
||||
core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`);
|
||||
} catch (err) {
|
||||
core.warning(`Assignment with agent_assignment failed: ${err.message}`);
|
||||
// Fallback: try without agent_assignment
|
||||
try {
|
||||
await github.rest.issues.addAssignees({
|
||||
owner, repo, issue_number,
|
||||
assignees: ['copilot-swe-agent']
|
||||
});
|
||||
core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`);
|
||||
} catch (err2) {
|
||||
core.warning(`Fallback also failed: ${err2.message}`);
|
||||
}
|
||||
}
|
||||
name: Squad Issue Assign
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
assign-work:
|
||||
# Only trigger on squad:{member} labels (not the base "squad" label)
|
||||
if: startsWith(github.event.label.name, 'squad:')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Identify assigned member and trigger work
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const issue = context.payload.issue;
|
||||
const label = context.payload.label.name;
|
||||
|
||||
// Extract member name from label (e.g., "squad:ripley" → "ripley")
|
||||
const memberName = label.replace('squad:', '').toLowerCase();
|
||||
|
||||
// Read team roster — check .squad/ first, fall back to .ai-team/
|
||||
let teamFile = '.squad/team.md';
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
teamFile = '.ai-team/team.md';
|
||||
}
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
core.warning('No .squad/team.md or .ai-team/team.md found — cannot assign work');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(teamFile, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Check if this is a coding agent assignment
|
||||
const isCopilotAssignment = memberName === 'copilot';
|
||||
|
||||
let assignedMember = null;
|
||||
if (isCopilotAssignment) {
|
||||
assignedMember = { name: '@copilot', role: 'Coding Agent' };
|
||||
} else {
|
||||
let inMembersTable = false;
|
||||
for (const line of lines) {
|
||||
if (line.match(/^##\s+(Members|Team Roster)/i)) {
|
||||
inMembersTable = true;
|
||||
continue;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('## ')) {
|
||||
break;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
|
||||
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
||||
if (cells.length >= 2 && cells[0].toLowerCase() === memberName) {
|
||||
assignedMember = { name: cells[0], role: cells[1] };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!assignedMember) {
|
||||
core.warning(`No member found matching label "${label}"`);
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `⚠️ No squad member found matching label \`${label}\`. Check \`.squad/team.md\` (or \`.ai-team/team.md\`) for valid member names.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Post assignment acknowledgment
|
||||
let comment;
|
||||
if (isCopilotAssignment) {
|
||||
comment = [
|
||||
`### 🤖 Routed to @copilot (Coding Agent)`,
|
||||
'',
|
||||
`**Issue:** #${issue.number} — ${issue.title}`,
|
||||
'',
|
||||
`@copilot has been assigned and will pick this up automatically.`,
|
||||
'',
|
||||
`> The coding agent will create a \`copilot/*\` branch and open a draft PR.`,
|
||||
`> Review the PR as you would any team member's work.`,
|
||||
].join('\n');
|
||||
} else {
|
||||
comment = [
|
||||
`### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`,
|
||||
'',
|
||||
`**Issue:** #${issue.number} — ${issue.title}`,
|
||||
'',
|
||||
`${assignedMember.name} will pick this up in the next Copilot session.`,
|
||||
'',
|
||||
`> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`,
|
||||
`> Otherwise, start a Copilot session and say:`,
|
||||
`> \`${assignedMember.name}, work on issue #${issue.number}\``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`);
|
||||
|
||||
# Separate step: assign @copilot using PAT (required for coding agent)
|
||||
- name: Assign @copilot coding agent
|
||||
if: github.event.label.name == 'squad:copilot'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const issue_number = context.payload.issue.number;
|
||||
|
||||
// Get the default branch name (main, master, etc.)
|
||||
const { data: repoData } = await github.rest.repos.get({ owner, repo });
|
||||
const baseBranch = repoData.default_branch;
|
||||
|
||||
try {
|
||||
await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
assignees: ['copilot-swe-agent[bot]'],
|
||||
agent_assignment: {
|
||||
target_repo: `${owner}/${repo}`,
|
||||
base_branch: baseBranch,
|
||||
custom_instructions: '',
|
||||
custom_agent: '',
|
||||
model: ''
|
||||
},
|
||||
headers: {
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
}
|
||||
});
|
||||
core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`);
|
||||
} catch (err) {
|
||||
core.warning(`Assignment with agent_assignment failed: ${err.message}`);
|
||||
// Fallback: try without agent_assignment
|
||||
try {
|
||||
await github.rest.issues.addAssignees({
|
||||
owner, repo, issue_number,
|
||||
assignees: ['copilot-swe-agent']
|
||||
});
|
||||
core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`);
|
||||
} catch (err2) {
|
||||
core.warning(`Fallback also failed: ${err2.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
+260
-260
@@ -1,260 +1,260 @@
|
||||
name: Squad Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
if: github.event.label.name == 'squad'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Triage issue via Lead agent
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const issue = context.payload.issue;
|
||||
|
||||
// Read team roster — check .squad/ first, fall back to .ai-team/
|
||||
let teamFile = '.squad/team.md';
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
teamFile = '.ai-team/team.md';
|
||||
}
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
core.warning('No .squad/team.md or .ai-team/team.md found — cannot triage');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(teamFile, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Check if @copilot is on the team
|
||||
const hasCopilot = content.includes('🤖 Coding Agent');
|
||||
const copilotAutoAssign = content.includes('<!-- copilot-auto-assign: true -->');
|
||||
|
||||
// Parse @copilot capability profile
|
||||
let goodFitKeywords = [];
|
||||
let needsReviewKeywords = [];
|
||||
let notSuitableKeywords = [];
|
||||
|
||||
if (hasCopilot) {
|
||||
// Extract capability tiers from team.md
|
||||
const goodFitMatch = content.match(/🟢\s*Good fit[^:]*:\s*(.+)/i);
|
||||
const needsReviewMatch = content.match(/🟡\s*Needs review[^:]*:\s*(.+)/i);
|
||||
const notSuitableMatch = content.match(/🔴\s*Not suitable[^:]*:\s*(.+)/i);
|
||||
|
||||
if (goodFitMatch) {
|
||||
goodFitKeywords = goodFitMatch[1].toLowerCase().split(',').map(s => s.trim());
|
||||
} else {
|
||||
goodFitKeywords = ['bug fix', 'test coverage', 'lint', 'format', 'dependency update', 'small feature', 'scaffolding', 'doc fix', 'documentation'];
|
||||
}
|
||||
if (needsReviewMatch) {
|
||||
needsReviewKeywords = needsReviewMatch[1].toLowerCase().split(',').map(s => s.trim());
|
||||
} else {
|
||||
needsReviewKeywords = ['medium feature', 'refactoring', 'api endpoint', 'migration'];
|
||||
}
|
||||
if (notSuitableMatch) {
|
||||
notSuitableKeywords = notSuitableMatch[1].toLowerCase().split(',').map(s => s.trim());
|
||||
} else {
|
||||
notSuitableKeywords = ['architecture', 'system design', 'security', 'auth', 'encryption', 'performance'];
|
||||
}
|
||||
}
|
||||
|
||||
const members = [];
|
||||
let inMembersTable = false;
|
||||
for (const line of lines) {
|
||||
if (line.match(/^##\s+(Members|Team Roster)/i)) {
|
||||
inMembersTable = true;
|
||||
continue;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('## ')) {
|
||||
break;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
|
||||
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
||||
if (cells.length >= 2 && cells[0] !== 'Scribe') {
|
||||
members.push({
|
||||
name: cells[0],
|
||||
role: cells[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read routing rules — check .squad/ first, fall back to .ai-team/
|
||||
let routingFile = '.squad/routing.md';
|
||||
if (!fs.existsSync(routingFile)) {
|
||||
routingFile = '.ai-team/routing.md';
|
||||
}
|
||||
let routingContent = '';
|
||||
if (fs.existsSync(routingFile)) {
|
||||
routingContent = fs.readFileSync(routingFile, 'utf8');
|
||||
}
|
||||
|
||||
// Find the Lead
|
||||
const lead = members.find(m =>
|
||||
m.role.toLowerCase().includes('lead') ||
|
||||
m.role.toLowerCase().includes('architect') ||
|
||||
m.role.toLowerCase().includes('coordinator')
|
||||
);
|
||||
|
||||
if (!lead) {
|
||||
core.warning('No Lead role found in team roster — cannot triage');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build triage context
|
||||
const memberList = members.map(m =>
|
||||
`- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\``
|
||||
).join('\n');
|
||||
|
||||
// Determine best assignee based on issue content and routing
|
||||
const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase();
|
||||
|
||||
let assignedMember = null;
|
||||
let triageReason = '';
|
||||
let copilotTier = null;
|
||||
|
||||
// First, evaluate @copilot fit if enabled
|
||||
if (hasCopilot) {
|
||||
const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw));
|
||||
const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw));
|
||||
const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw));
|
||||
|
||||
if (isGoodFit) {
|
||||
copilotTier = 'good-fit';
|
||||
assignedMember = { name: '@copilot', role: 'Coding Agent' };
|
||||
triageReason = '🟢 Good fit for @copilot — matches capability profile';
|
||||
} else if (isNeedsReview) {
|
||||
copilotTier = 'needs-review';
|
||||
assignedMember = { name: '@copilot', role: 'Coding Agent' };
|
||||
triageReason = '🟡 Routing to @copilot (needs review) — a squad member should review the PR';
|
||||
} else if (isNotSuitable) {
|
||||
copilotTier = 'not-suitable';
|
||||
// Fall through to normal routing
|
||||
}
|
||||
}
|
||||
|
||||
// If not routed to @copilot, use keyword-based routing
|
||||
if (!assignedMember) {
|
||||
for (const member of members) {
|
||||
const role = member.role.toLowerCase();
|
||||
if ((role.includes('frontend') || role.includes('ui')) &&
|
||||
(issueText.includes('ui') || issueText.includes('frontend') ||
|
||||
issueText.includes('css') || issueText.includes('component') ||
|
||||
issueText.includes('button') || issueText.includes('page') ||
|
||||
issueText.includes('layout') || issueText.includes('design'))) {
|
||||
assignedMember = member;
|
||||
triageReason = 'Issue relates to frontend/UI work';
|
||||
break;
|
||||
}
|
||||
if ((role.includes('backend') || role.includes('api') || role.includes('server')) &&
|
||||
(issueText.includes('api') || issueText.includes('backend') ||
|
||||
issueText.includes('database') || issueText.includes('endpoint') ||
|
||||
issueText.includes('server') || issueText.includes('auth'))) {
|
||||
assignedMember = member;
|
||||
triageReason = 'Issue relates to backend/API work';
|
||||
break;
|
||||
}
|
||||
if ((role.includes('test') || role.includes('qa') || role.includes('quality')) &&
|
||||
(issueText.includes('test') || issueText.includes('bug') ||
|
||||
issueText.includes('fix') || issueText.includes('regression') ||
|
||||
issueText.includes('coverage'))) {
|
||||
assignedMember = member;
|
||||
triageReason = 'Issue relates to testing/quality work';
|
||||
break;
|
||||
}
|
||||
if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) &&
|
||||
(issueText.includes('deploy') || issueText.includes('ci') ||
|
||||
issueText.includes('pipeline') || issueText.includes('docker') ||
|
||||
issueText.includes('infrastructure'))) {
|
||||
assignedMember = member;
|
||||
triageReason = 'Issue relates to DevOps/infrastructure work';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to Lead if no routing match
|
||||
if (!assignedMember) {
|
||||
assignedMember = lead;
|
||||
triageReason = 'No specific domain match — assigned to Lead for further analysis';
|
||||
}
|
||||
|
||||
const isCopilot = assignedMember.name === '@copilot';
|
||||
const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`;
|
||||
|
||||
// Add the member-specific label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [assignLabel]
|
||||
});
|
||||
|
||||
// Apply default triage verdict
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['go:needs-research']
|
||||
});
|
||||
|
||||
// Auto-assign @copilot if enabled
|
||||
if (isCopilot && copilotAutoAssign) {
|
||||
try {
|
||||
await github.rest.issues.addAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
assignees: ['copilot']
|
||||
});
|
||||
} catch (err) {
|
||||
core.warning(`Could not auto-assign @copilot: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build copilot evaluation note
|
||||
let copilotNote = '';
|
||||
if (hasCopilot && !isCopilot) {
|
||||
if (copilotTier === 'not-suitable') {
|
||||
copilotNote = `\n\n**@copilot evaluation:** 🔴 Not suitable — issue involves work outside the coding agent's capability profile.`;
|
||||
} else {
|
||||
copilotNote = `\n\n**@copilot evaluation:** No strong capability match — routed to squad member.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Post triage comment
|
||||
const comment = [
|
||||
`### 🏗️ Squad Triage — ${lead.name} (${lead.role})`,
|
||||
'',
|
||||
`**Issue:** #${issue.number} — ${issue.title}`,
|
||||
`**Assigned to:** ${assignedMember.name} (${assignedMember.role})`,
|
||||
`**Reason:** ${triageReason}`,
|
||||
copilotTier === 'needs-review' ? `\n⚠️ **PR review recommended** — a squad member should review @copilot's work on this one.` : '',
|
||||
copilotNote,
|
||||
'',
|
||||
`---`,
|
||||
'',
|
||||
`**Team roster:**`,
|
||||
memberList,
|
||||
hasCopilot ? `- **@copilot** (Coding Agent) → label: \`squad:copilot\`` : '',
|
||||
'',
|
||||
`> To reassign, remove the current \`squad:*\` label and add the correct one.`,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`);
|
||||
name: Squad Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
if: github.event.label.name == 'squad'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Triage issue via Lead agent
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const issue = context.payload.issue;
|
||||
|
||||
// Read team roster — check .squad/ first, fall back to .ai-team/
|
||||
let teamFile = '.squad/team.md';
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
teamFile = '.ai-team/team.md';
|
||||
}
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
core.warning('No .squad/team.md or .ai-team/team.md found — cannot triage');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(teamFile, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Check if @copilot is on the team
|
||||
const hasCopilot = content.includes('🤖 Coding Agent');
|
||||
const copilotAutoAssign = content.includes('<!-- copilot-auto-assign: true -->');
|
||||
|
||||
// Parse @copilot capability profile
|
||||
let goodFitKeywords = [];
|
||||
let needsReviewKeywords = [];
|
||||
let notSuitableKeywords = [];
|
||||
|
||||
if (hasCopilot) {
|
||||
// Extract capability tiers from team.md
|
||||
const goodFitMatch = content.match(/🟢\s*Good fit[^:]*:\s*(.+)/i);
|
||||
const needsReviewMatch = content.match(/🟡\s*Needs review[^:]*:\s*(.+)/i);
|
||||
const notSuitableMatch = content.match(/🔴\s*Not suitable[^:]*:\s*(.+)/i);
|
||||
|
||||
if (goodFitMatch) {
|
||||
goodFitKeywords = goodFitMatch[1].toLowerCase().split(',').map(s => s.trim());
|
||||
} else {
|
||||
goodFitKeywords = ['bug fix', 'test coverage', 'lint', 'format', 'dependency update', 'small feature', 'scaffolding', 'doc fix', 'documentation'];
|
||||
}
|
||||
if (needsReviewMatch) {
|
||||
needsReviewKeywords = needsReviewMatch[1].toLowerCase().split(',').map(s => s.trim());
|
||||
} else {
|
||||
needsReviewKeywords = ['medium feature', 'refactoring', 'api endpoint', 'migration'];
|
||||
}
|
||||
if (notSuitableMatch) {
|
||||
notSuitableKeywords = notSuitableMatch[1].toLowerCase().split(',').map(s => s.trim());
|
||||
} else {
|
||||
notSuitableKeywords = ['architecture', 'system design', 'security', 'auth', 'encryption', 'performance'];
|
||||
}
|
||||
}
|
||||
|
||||
const members = [];
|
||||
let inMembersTable = false;
|
||||
for (const line of lines) {
|
||||
if (line.match(/^##\s+(Members|Team Roster)/i)) {
|
||||
inMembersTable = true;
|
||||
continue;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('## ')) {
|
||||
break;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
|
||||
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
||||
if (cells.length >= 2 && cells[0] !== 'Scribe') {
|
||||
members.push({
|
||||
name: cells[0],
|
||||
role: cells[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read routing rules — check .squad/ first, fall back to .ai-team/
|
||||
let routingFile = '.squad/routing.md';
|
||||
if (!fs.existsSync(routingFile)) {
|
||||
routingFile = '.ai-team/routing.md';
|
||||
}
|
||||
let routingContent = '';
|
||||
if (fs.existsSync(routingFile)) {
|
||||
routingContent = fs.readFileSync(routingFile, 'utf8');
|
||||
}
|
||||
|
||||
// Find the Lead
|
||||
const lead = members.find(m =>
|
||||
m.role.toLowerCase().includes('lead') ||
|
||||
m.role.toLowerCase().includes('architect') ||
|
||||
m.role.toLowerCase().includes('coordinator')
|
||||
);
|
||||
|
||||
if (!lead) {
|
||||
core.warning('No Lead role found in team roster — cannot triage');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build triage context
|
||||
const memberList = members.map(m =>
|
||||
`- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\``
|
||||
).join('\n');
|
||||
|
||||
// Determine best assignee based on issue content and routing
|
||||
const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase();
|
||||
|
||||
let assignedMember = null;
|
||||
let triageReason = '';
|
||||
let copilotTier = null;
|
||||
|
||||
// First, evaluate @copilot fit if enabled
|
||||
if (hasCopilot) {
|
||||
const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw));
|
||||
const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw));
|
||||
const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw));
|
||||
|
||||
if (isGoodFit) {
|
||||
copilotTier = 'good-fit';
|
||||
assignedMember = { name: '@copilot', role: 'Coding Agent' };
|
||||
triageReason = '🟢 Good fit for @copilot — matches capability profile';
|
||||
} else if (isNeedsReview) {
|
||||
copilotTier = 'needs-review';
|
||||
assignedMember = { name: '@copilot', role: 'Coding Agent' };
|
||||
triageReason = '🟡 Routing to @copilot (needs review) — a squad member should review the PR';
|
||||
} else if (isNotSuitable) {
|
||||
copilotTier = 'not-suitable';
|
||||
// Fall through to normal routing
|
||||
}
|
||||
}
|
||||
|
||||
// If not routed to @copilot, use keyword-based routing
|
||||
if (!assignedMember) {
|
||||
for (const member of members) {
|
||||
const role = member.role.toLowerCase();
|
||||
if ((role.includes('frontend') || role.includes('ui')) &&
|
||||
(issueText.includes('ui') || issueText.includes('frontend') ||
|
||||
issueText.includes('css') || issueText.includes('component') ||
|
||||
issueText.includes('button') || issueText.includes('page') ||
|
||||
issueText.includes('layout') || issueText.includes('design'))) {
|
||||
assignedMember = member;
|
||||
triageReason = 'Issue relates to frontend/UI work';
|
||||
break;
|
||||
}
|
||||
if ((role.includes('backend') || role.includes('api') || role.includes('server')) &&
|
||||
(issueText.includes('api') || issueText.includes('backend') ||
|
||||
issueText.includes('database') || issueText.includes('endpoint') ||
|
||||
issueText.includes('server') || issueText.includes('auth'))) {
|
||||
assignedMember = member;
|
||||
triageReason = 'Issue relates to backend/API work';
|
||||
break;
|
||||
}
|
||||
if ((role.includes('test') || role.includes('qa') || role.includes('quality')) &&
|
||||
(issueText.includes('test') || issueText.includes('bug') ||
|
||||
issueText.includes('fix') || issueText.includes('regression') ||
|
||||
issueText.includes('coverage'))) {
|
||||
assignedMember = member;
|
||||
triageReason = 'Issue relates to testing/quality work';
|
||||
break;
|
||||
}
|
||||
if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) &&
|
||||
(issueText.includes('deploy') || issueText.includes('ci') ||
|
||||
issueText.includes('pipeline') || issueText.includes('docker') ||
|
||||
issueText.includes('infrastructure'))) {
|
||||
assignedMember = member;
|
||||
triageReason = 'Issue relates to DevOps/infrastructure work';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to Lead if no routing match
|
||||
if (!assignedMember) {
|
||||
assignedMember = lead;
|
||||
triageReason = 'No specific domain match — assigned to Lead for further analysis';
|
||||
}
|
||||
|
||||
const isCopilot = assignedMember.name === '@copilot';
|
||||
const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`;
|
||||
|
||||
// Add the member-specific label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [assignLabel]
|
||||
});
|
||||
|
||||
// Apply default triage verdict
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['go:needs-research']
|
||||
});
|
||||
|
||||
// Auto-assign @copilot if enabled
|
||||
if (isCopilot && copilotAutoAssign) {
|
||||
try {
|
||||
await github.rest.issues.addAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
assignees: ['copilot']
|
||||
});
|
||||
} catch (err) {
|
||||
core.warning(`Could not auto-assign @copilot: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build copilot evaluation note
|
||||
let copilotNote = '';
|
||||
if (hasCopilot && !isCopilot) {
|
||||
if (copilotTier === 'not-suitable') {
|
||||
copilotNote = `\n\n**@copilot evaluation:** 🔴 Not suitable — issue involves work outside the coding agent's capability profile.`;
|
||||
} else {
|
||||
copilotNote = `\n\n**@copilot evaluation:** No strong capability match — routed to squad member.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Post triage comment
|
||||
const comment = [
|
||||
`### 🏗️ Squad Triage — ${lead.name} (${lead.role})`,
|
||||
'',
|
||||
`**Issue:** #${issue.number} — ${issue.title}`,
|
||||
`**Assigned to:** ${assignedMember.name} (${assignedMember.role})`,
|
||||
`**Reason:** ${triageReason}`,
|
||||
copilotTier === 'needs-review' ? `\n⚠️ **PR review recommended** — a squad member should review @copilot's work on this one.` : '',
|
||||
copilotNote,
|
||||
'',
|
||||
`---`,
|
||||
'',
|
||||
`**Team roster:**`,
|
||||
memberList,
|
||||
hasCopilot ? `- **@copilot** (Coding Agent) → label: \`squad:copilot\`` : '',
|
||||
'',
|
||||
`> To reassign, remove the current \`squad:*\` label and add the correct one.`,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`);
|
||||
|
||||
@@ -1,169 +1,169 @@
|
||||
name: Sync Squad Labels
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.squad/team.md'
|
||||
- '.ai-team/team.md'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Parse roster and sync labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
let teamFile = '.squad/team.md';
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
teamFile = '.ai-team/team.md';
|
||||
}
|
||||
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
core.info('No .squad/team.md or .ai-team/team.md found — skipping label sync');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(teamFile, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Parse the Members table for agent names
|
||||
const members = [];
|
||||
let inMembersTable = false;
|
||||
for (const line of lines) {
|
||||
if (line.match(/^##\s+(Members|Team Roster)/i)) {
|
||||
inMembersTable = true;
|
||||
continue;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('## ')) {
|
||||
break;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
|
||||
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
||||
if (cells.length >= 2 && cells[0] !== 'Scribe') {
|
||||
members.push({
|
||||
name: cells[0],
|
||||
role: cells[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`Found ${members.length} squad members: ${members.map(m => m.name).join(', ')}`);
|
||||
|
||||
// Check if @copilot is on the team
|
||||
const hasCopilot = content.includes('🤖 Coding Agent');
|
||||
|
||||
// Define label color palette for squad labels
|
||||
const SQUAD_COLOR = '9B8FCC';
|
||||
const MEMBER_COLOR = '9B8FCC';
|
||||
const COPILOT_COLOR = '10b981';
|
||||
|
||||
// Define go: and release: labels (static)
|
||||
const GO_LABELS = [
|
||||
{ name: 'go:yes', color: '0E8A16', description: 'Ready to implement' },
|
||||
{ name: 'go:no', color: 'B60205', description: 'Not pursuing' },
|
||||
{ name: 'go:needs-research', color: 'FBCA04', description: 'Needs investigation' }
|
||||
];
|
||||
|
||||
const RELEASE_LABELS = [
|
||||
{ name: 'release:v0.4.0', color: '6B8EB5', description: 'Targeted for v0.4.0' },
|
||||
{ name: 'release:v0.5.0', color: '6B8EB5', description: 'Targeted for v0.5.0' },
|
||||
{ name: 'release:v0.6.0', color: '8B7DB5', description: 'Targeted for v0.6.0' },
|
||||
{ name: 'release:v1.0.0', color: '8B7DB5', description: 'Targeted for v1.0.0' },
|
||||
{ name: 'release:backlog', color: 'D4E5F7', description: 'Not yet targeted' }
|
||||
];
|
||||
|
||||
const TYPE_LABELS = [
|
||||
{ name: 'type:feature', color: 'DDD1F2', description: 'New capability' },
|
||||
{ name: 'type:bug', color: 'FF0422', description: 'Something broken' },
|
||||
{ name: 'type:spike', color: 'F2DDD4', description: 'Research/investigation — produces a plan, not code' },
|
||||
{ name: 'type:docs', color: 'D4E5F7', description: 'Documentation work' },
|
||||
{ name: 'type:chore', color: 'D4E5F7', description: 'Maintenance, refactoring, cleanup' },
|
||||
{ name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' }
|
||||
];
|
||||
|
||||
// High-signal labels — these MUST visually dominate all others
|
||||
const SIGNAL_LABELS = [
|
||||
{ name: 'bug', color: 'FF0422', description: 'Something isn\'t working' },
|
||||
{ name: 'feedback', color: '00E5FF', description: 'User feedback — high signal, needs attention' }
|
||||
];
|
||||
|
||||
const PRIORITY_LABELS = [
|
||||
{ name: 'priority:p0', color: 'B60205', description: 'Blocking release' },
|
||||
{ name: 'priority:p1', color: 'D93F0B', description: 'This sprint' },
|
||||
{ name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' }
|
||||
];
|
||||
|
||||
// Ensure the base "squad" triage label exists
|
||||
const labels = [
|
||||
{ name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' }
|
||||
];
|
||||
|
||||
for (const member of members) {
|
||||
labels.push({
|
||||
name: `squad:${member.name.toLowerCase()}`,
|
||||
color: MEMBER_COLOR,
|
||||
description: `Assigned to ${member.name} (${member.role})`
|
||||
});
|
||||
}
|
||||
|
||||
// Add @copilot label if coding agent is on the team
|
||||
if (hasCopilot) {
|
||||
labels.push({
|
||||
name: 'squad:copilot',
|
||||
color: COPILOT_COLOR,
|
||||
description: 'Assigned to @copilot (Coding Agent) for autonomous work'
|
||||
});
|
||||
}
|
||||
|
||||
// Add go:, release:, type:, priority:, and high-signal labels
|
||||
labels.push(...GO_LABELS);
|
||||
labels.push(...RELEASE_LABELS);
|
||||
labels.push(...TYPE_LABELS);
|
||||
labels.push(...PRIORITY_LABELS);
|
||||
labels.push(...SIGNAL_LABELS);
|
||||
|
||||
// Sync labels (create or update)
|
||||
for (const label of labels) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label.name
|
||||
});
|
||||
// Label exists — update it
|
||||
await github.rest.issues.updateLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: label.description
|
||||
});
|
||||
core.info(`Updated label: ${label.name}`);
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
// Label doesn't exist — create it
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: label.description
|
||||
});
|
||||
core.info(`Created label: ${label.name}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`Label sync complete: ${labels.length} labels synced`);
|
||||
name: Sync Squad Labels
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.squad/team.md'
|
||||
- '.ai-team/team.md'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Parse roster and sync labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
let teamFile = '.squad/team.md';
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
teamFile = '.ai-team/team.md';
|
||||
}
|
||||
|
||||
if (!fs.existsSync(teamFile)) {
|
||||
core.info('No .squad/team.md or .ai-team/team.md found — skipping label sync');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(teamFile, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Parse the Members table for agent names
|
||||
const members = [];
|
||||
let inMembersTable = false;
|
||||
for (const line of lines) {
|
||||
if (line.match(/^##\s+(Members|Team Roster)/i)) {
|
||||
inMembersTable = true;
|
||||
continue;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('## ')) {
|
||||
break;
|
||||
}
|
||||
if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
|
||||
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
||||
if (cells.length >= 2 && cells[0] !== 'Scribe') {
|
||||
members.push({
|
||||
name: cells[0],
|
||||
role: cells[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`Found ${members.length} squad members: ${members.map(m => m.name).join(', ')}`);
|
||||
|
||||
// Check if @copilot is on the team
|
||||
const hasCopilot = content.includes('🤖 Coding Agent');
|
||||
|
||||
// Define label color palette for squad labels
|
||||
const SQUAD_COLOR = '9B8FCC';
|
||||
const MEMBER_COLOR = '9B8FCC';
|
||||
const COPILOT_COLOR = '10b981';
|
||||
|
||||
// Define go: and release: labels (static)
|
||||
const GO_LABELS = [
|
||||
{ name: 'go:yes', color: '0E8A16', description: 'Ready to implement' },
|
||||
{ name: 'go:no', color: 'B60205', description: 'Not pursuing' },
|
||||
{ name: 'go:needs-research', color: 'FBCA04', description: 'Needs investigation' }
|
||||
];
|
||||
|
||||
const RELEASE_LABELS = [
|
||||
{ name: 'release:v0.4.0', color: '6B8EB5', description: 'Targeted for v0.4.0' },
|
||||
{ name: 'release:v0.5.0', color: '6B8EB5', description: 'Targeted for v0.5.0' },
|
||||
{ name: 'release:v0.6.0', color: '8B7DB5', description: 'Targeted for v0.6.0' },
|
||||
{ name: 'release:v1.0.0', color: '8B7DB5', description: 'Targeted for v1.0.0' },
|
||||
{ name: 'release:backlog', color: 'D4E5F7', description: 'Not yet targeted' }
|
||||
];
|
||||
|
||||
const TYPE_LABELS = [
|
||||
{ name: 'type:feature', color: 'DDD1F2', description: 'New capability' },
|
||||
{ name: 'type:bug', color: 'FF0422', description: 'Something broken' },
|
||||
{ name: 'type:spike', color: 'F2DDD4', description: 'Research/investigation — produces a plan, not code' },
|
||||
{ name: 'type:docs', color: 'D4E5F7', description: 'Documentation work' },
|
||||
{ name: 'type:chore', color: 'D4E5F7', description: 'Maintenance, refactoring, cleanup' },
|
||||
{ name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' }
|
||||
];
|
||||
|
||||
// High-signal labels — these MUST visually dominate all others
|
||||
const SIGNAL_LABELS = [
|
||||
{ name: 'bug', color: 'FF0422', description: 'Something isn\'t working' },
|
||||
{ name: 'feedback', color: '00E5FF', description: 'User feedback — high signal, needs attention' }
|
||||
];
|
||||
|
||||
const PRIORITY_LABELS = [
|
||||
{ name: 'priority:p0', color: 'B60205', description: 'Blocking release' },
|
||||
{ name: 'priority:p1', color: 'D93F0B', description: 'This sprint' },
|
||||
{ name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' }
|
||||
];
|
||||
|
||||
// Ensure the base "squad" triage label exists
|
||||
const labels = [
|
||||
{ name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' }
|
||||
];
|
||||
|
||||
for (const member of members) {
|
||||
labels.push({
|
||||
name: `squad:${member.name.toLowerCase()}`,
|
||||
color: MEMBER_COLOR,
|
||||
description: `Assigned to ${member.name} (${member.role})`
|
||||
});
|
||||
}
|
||||
|
||||
// Add @copilot label if coding agent is on the team
|
||||
if (hasCopilot) {
|
||||
labels.push({
|
||||
name: 'squad:copilot',
|
||||
color: COPILOT_COLOR,
|
||||
description: 'Assigned to @copilot (Coding Agent) for autonomous work'
|
||||
});
|
||||
}
|
||||
|
||||
// Add go:, release:, type:, priority:, and high-signal labels
|
||||
labels.push(...GO_LABELS);
|
||||
labels.push(...RELEASE_LABELS);
|
||||
labels.push(...TYPE_LABELS);
|
||||
labels.push(...PRIORITY_LABELS);
|
||||
labels.push(...SIGNAL_LABELS);
|
||||
|
||||
// Sync labels (create or update)
|
||||
for (const label of labels) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label.name
|
||||
});
|
||||
// Label exists — update it
|
||||
await github.rest.issues.updateLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: label.description
|
||||
});
|
||||
core.info(`Updated label: ${label.name}`);
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
// Label doesn't exist — create it
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: label.description
|
||||
});
|
||||
core.info(`Created label: ${label.name}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`Label sync complete: ${labels.length} labels synced`);
|
||||
|
||||
Reference in New Issue
Block a user