Skip to content

test: Phase 4B GitHub API Integration Tests (118 tests) #13996

test: Phase 4B GitHub API Integration Tests (118 tests)

test: Phase 4B GitHub API Integration Tests (118 tests) #13996

name: Projects • Add & Sync meta from labels
on:
issues:
types: [opened, edited, labeled, unlabeled, reopened, closed]
pull_request:
branches: [develop]
types:
[opened, edited, labeled, unlabeled, reopened, ready_for_review, closed]
permissions:
contents: read
issues: write
pull-requests: write
env:
# Prefer explicit URL; derive from LS_PROJECT_NUMBER if only number is set.
# e.g. set vars.LS_PROJECT_NUMBER=33 → https://github.com/orgs/lightspeedwp/projects/33
PROJECT_URL: ${{ vars.LS_PROJECT_URL || (vars.LS_PROJECT_NUMBER && format('https://github.com/orgs/{0}/projects/{1}', github.repository_owner, vars.LS_PROJECT_NUMBER)) || '' }}
SLA_WARN_DAYS: "7"
SLA_BREACH_DAYS: "14"
concurrency:
group: project-meta-sync-${{ github.event_name }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
add-and-sync:
runs-on: ubuntu-latest
steps:
- name: Preflight project sync configuration
id: preflight
env:
APP_ID: ${{ vars.LS_APP_ID }}
APP_PRIVATE_KEY: ${{ secrets.LS_APP_PRIVATE_KEY }}
run: |
set -euo pipefail
enabled="true"
reason=""
# If neither PROJECT_URL nor LS_PROJECT_NUMBER is set, disable gracefully
if [ -z "${PROJECT_URL:-}" ]; then
enabled="false"
reason="Neither LS_PROJECT_URL nor LS_PROJECT_NUMBER is configured (project sync disabled)"
echo "::notice::Project sync disabled: $reason"
# If PROJECT_URL is set but app credentials are missing, fail loudly
elif [ -z "${APP_ID:-}" ] || [ -z "${APP_PRIVATE_KEY:-}" ]; then
echo "::error::Project sync REQUIRES GitHub App credentials (LS_PROJECT_URL is set)"
echo "Missing:"
[ -z "${APP_ID:-}" ] && echo " - vars.LS_APP_ID"
[ -z "${APP_PRIVATE_KEY:-}" ] && echo " - secrets.LS_APP_PRIVATE_KEY"
echo ""
echo "Setup: 1. Create GitHub App in org settings (AI Ops + Project v2 scopes)"
echo " 2. Set vars.LS_APP_ID and secrets.LS_APP_PRIVATE_KEY"
echo " 3. Re-run this workflow"
echo ""
echo "Docs: see .github/workflows/project-meta-sync.yml and docs/AUTOMATION.md"
exit 1
fi
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
if [ "$enabled" = "true" ]; then
echo "✅ Project sync enabled (credentials present)"
fi
- name: Create GitHub App installation token
id: app-token
if: steps.preflight.outputs.enabled == 'true'
uses: actions/create-github-app-token@v3
with:
app-id: ${{ vars.LS_APP_ID }}
private-key: ${{ secrets.LS_APP_PRIVATE_KEY }}
# Without explicit permission-* inputs the token inherits every
# permission the App installation holds. Scope it to what this job
# actually does:
# organization-projects : add-to-project and update-project-fields
# operate on the org Projects v2 board
# issues / pull-requests: resolve the item being added to the board
permission-organization-projects: write
permission-issues: write
permission-pull-requests: write
- name: Add item to project (new issues/PRs)
id: addp
if: steps.preflight.outputs.enabled == 'true'
uses: actions/add-to-project@v1.0.2
with:
project-url: ${{ env.PROJECT_URL }}
github-token: ${{ steps.app-token.outputs.token }}
- name: Checkout repository
if: steps.preflight.outputs.enabled == 'true'
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Set up Node.js
if: steps.preflight.outputs.enabled == 'true'
uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
- name: Install workflow dependencies
if: steps.preflight.outputs.enabled == 'true'
run: npm ci --ignore-scripts
- name: Collect labels from item
id: collect-labels
if: steps.preflight.outputs.enabled == 'true'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const labels = context.payload.issue?.labels || context.payload.pull_request?.labels || [];
const labelNames = labels.map((label) => label.name).filter(Boolean).join('\n');
core.setOutput('labels', labelNames);
- name: Derive Status/Priority/Type from canonical issue fields
id: derive
if: steps.preflight.outputs.enabled == 'true'
run: node scripts/agents/includes/derive-project-fields.cjs
env:
LABELS: ${{ steps.collect-labels.outputs.labels }}
EVENT_NAME: ${{ github.event_name }}
EVENT_ACTION: ${{ github.event.action }}
PR_MERGED: ${{ github.event.pull_request.merged }}
ITEM_CREATED_AT: ${{ github.event.issue.created_at || github.event.pull_request.created_at }}
ITEM_MILESTONE_DUE_ON: ${{ github.event.issue.milestone.due_on || github.event.pull_request.milestone.due_on }}
ITEM_TITLE: ${{ github.event.issue.title || github.event.pull_request.title }}
ITEM_BODY: ${{ github.event.issue.body || github.event.pull_request.body }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref || '' }}
ISSUE_FIELDS_CONFIG: .github/issue-fields.yml
- name: Update project fields
if: steps.preflight.outputs.enabled == 'true' && steps.addp.outputs.itemId != ''
uses: titoportas/update-project-fields@v0.1.0
with:
project-url: ${{ env.PROJECT_URL }}
github-token: ${{ steps.app-token.outputs.token }}
item-id: ${{ steps.addp.outputs.itemId }}
field-keys: Status,Priority,Type,Effort
field-values: ${{ steps.derive.outputs.status }},${{ steps.derive.outputs.priority }},${{ steps.derive.outputs.type }},${{ steps.derive.outputs.effort }}
- name: Update kickoff dates
if: steps.preflight.outputs.enabled == 'true' && steps.addp.outputs.itemId != '' && (steps.derive.outputs.start_date != '' || steps.derive.outputs.target_date != '')
uses: titoportas/update-project-fields@v0.1.0
with:
project-url: ${{ env.PROJECT_URL }}
github-token: ${{ steps.app-token.outputs.token }}
item-id: ${{ steps.addp.outputs.itemId }}
field-keys: Start date,Target date
field-values: ${{ steps.derive.outputs.start_date }},${{ steps.derive.outputs.target_date }}
- name: Add or update aging and SLA annotation
if: steps.preflight.outputs.enabled == 'true' && github.event.action != 'closed'
uses: actions/github-script@v7
env:
SLA_WARN_DAYS: ${{ env.SLA_WARN_DAYS }}
SLA_BREACH_DAYS: ${{ env.SLA_BREACH_DAYS }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const marker = "<!-- project-meta-sync:sla -->";
const isIssue = context.eventName === "issues";
const number = isIssue ? context.payload.issue.number : context.payload.pull_request.number;
const createdAt = new Date(
isIssue ? context.payload.issue.created_at : context.payload.pull_request.created_at,
);
const now = new Date();
const ageDays = Math.floor((now - createdAt) / (1000 * 60 * 60 * 24));
const warnDays = Number(process.env.SLA_WARN_DAYS || "7");
const breachDays = Number(process.env.SLA_BREACH_DAYS || "14");
let slaState = "Within SLA";
if (ageDays >= breachDays) slaState = "SLA breached";
else if (ageDays >= warnDays) slaState = "SLA risk";
const body = `${marker}
## ⏱️ Aging and SLA annotation
- Age: **${ageDays} day(s)**
- SLA state: **${slaState}**
- Thresholds: warn at ${warnDays} days, breach at ${breachDays} days
- Last updated: ${now.toISOString()}
_Maintained by project-meta-sync workflow._`;
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: number,
per_page: 100,
});
const existing = comments.find(
(c) =>
c.user?.type === "Bot" &&
typeof c.body === "string" &&
c.body.includes(marker),
);
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
core.info(`Updated SLA annotation on #${number}`);
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number: number,
body,
});
core.info(`Created SLA annotation on #${number}`);
}