Skip to content

fix: Remove unused variables in test files #2020

fix: Remove unused variables in test files

fix: Remove unused variables in test files #2020

name: Issue Project Field Sync

Check failure on line 1 in .github/workflows/issue-project-field-sync.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/issue-project-field-sync.yml

Invalid workflow file

(Line: 48, Col: 3): Unexpected value 'projects'
# Syncs open GitHub issues into a Projects v2 board and fills in
# Status, Priority, and Type fields based on the issue's labels.
#
# SETUP:
# 1. Go to https://github.com/orgs/lightspeedwp/projects and note
# the project number (the integer in the URL, e.g. /projects/5).
# 2. Add a repo or org-level Actions secret named GH_PROJECT_TOKEN
# with a PAT that has the `project` scope. If you only need
# repo-linked projects, the built-in GITHUB_TOKEN is sufficient
# when `projects: write` is set below.
# 3. Set the project number as a repo variable: Settings → Variables
# → Actions → New → Name: PROJECT_NUMBER, Value: <number>
# 4. The field names (Status / Priority / Type) are auto-discovered.
# If your project uses different names, update FIELD_STATUS,
# FIELD_PRIORITY, FIELD_TYPE in the env block below.
on:
workflow_dispatch:
inputs:
project_number:
description: "GitHub Projects v2 number (overrides PROJECT_NUMBER variable)"
required: false
type: string
dry_run:
description: "Dry run — report planned changes without applying them"
required: false
default: "false"
type: choice
options:
- "false"
- "true"
add_missing:
description: "Add issues not yet in the project"
required: false
default: "true"
type: choice
options:
- "true"
- "false"
schedule:
# Runs every Monday at 09:00 UTC (after issue-health-audit at 08:00)
- cron: "0 9 * * 1"
permissions:
issues: read
projects: write
env:
# Names of the project fields to sync — change these if your
# project uses different field names (case-insensitive match used).
FIELD_STATUS: "Status"
FIELD_PRIORITY: "Priority"
FIELD_TYPE: "Type"
jobs:
sync-project-fields:
name: Sync Issue Fields → Project
runs-on: ubuntu-latest
steps:
- name: Sync issues to project and fill fields
uses: actions/github-script@v7
env:
# Use a PAT with `project` scope if available; fall back to GITHUB_TOKEN.
# GITHUB_TOKEN works for repo-linked projects when projects: write is set.
GH_TOKEN: ${{ secrets.GH_PROJECT_TOKEN || github.token }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
ADD_MISSING: ${{ github.event.inputs.add_missing || 'true' }}
PROJECT_NUMBER_INPUT: ${{ github.event.inputs.project_number || vars.PROJECT_NUMBER || '' }}
with:
github-token: ${{ secrets.GH_PROJECT_TOKEN || github.token }}
script: |
const dryRun = process.env.DRY_RUN === 'true';
const addMissing = process.env.ADD_MISSING === 'true';
const projectNumberStr = process.env.PROJECT_NUMBER_INPUT;
if (!projectNumberStr) {
core.setFailed(
'No project number supplied. Provide it via the workflow_dispatch input ' +
'or set a repo/org variable named PROJECT_NUMBER.'
);
return;
}
const projectNumber = parseInt(projectNumberStr, 10);
const owner = context.repo.owner;
const repo = context.repo.repo;
const report = {
added: [],
updated: [],
skipped: [],
errors: [],
};
// ─── GraphQL helper ──────────────────────────────────────────
async function gql(query, variables = {}) {
return github.graphql(query, variables);
}
// ─── Step 1: Resolve project node ID and fields ──────────────
core.info(`Fetching project #${projectNumber} for org "${owner}"...`);
let projectId, statusField, priorityField, typeField;
try {
const projectData = await gql(`
query($owner: String!, $number: Int!) {
organization(login: $owner) {
projectV2(number: $number) {
id
title
fields(first: 30) {
nodes {
... on ProjectV2Field {
id name dataType
}
... on ProjectV2SingleSelectField {
id name dataType
options { id name }
}
... on ProjectV2IterationField {
id name dataType
}
}
}
}
}
}
`, { owner, number: projectNumber });
const project = projectData.organization.projectV2;
projectId = project.id;
core.info(`Found project: "${project.title}" (${projectId})`);
const fieldNameMatch = (field, target) =>
field.name.toLowerCase() === target.toLowerCase();
for (const field of project.fields.nodes) {
if (fieldNameMatch(field, process.env.FIELD_STATUS)) statusField = field;
if (fieldNameMatch(field, process.env.FIELD_PRIORITY)) priorityField = field;
if (fieldNameMatch(field, process.env.FIELD_TYPE)) typeField = field;
}
core.info(`Status field: ${statusField?.name || 'NOT FOUND'}`);
core.info(`Priority field: ${priorityField?.name || 'NOT FOUND'}`);
core.info(`Type field: ${typeField?.name || 'NOT FOUND'}`);
} catch (err) {
core.setFailed(`Failed to fetch project: ${err.message}`);
return;
}
// ─── Step 2: Build a map of issues already in the project ────
core.info('Fetching existing project items...');
let cursor = null;
const existingItems = new Map(); // issueNumber → projectItemId
do {
const itemsData = await gql(`
query($projectId: ID!, $cursor: String) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
content {
... on Issue { number }
}
}
}
}
}
}
`, { projectId, cursor });
const page = itemsData.node.items;
for (const item of page.nodes) {
if (item.content?.number != null) {
existingItems.set(item.content.number, item.id);
}
}
cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
} while (cursor);
core.info(`Project has ${existingItems.size} existing items.`);
// ─── Step 3: Fetch all open issues ───────────────────────────
core.info('Fetching open issues...');
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: 'open',
per_page: 100,
});
// listForRepo returns PRs too — filter them out
const issues = openIssues.filter(i => !i.pull_request);
core.info(`Found ${issues.length} open issues.`);
// ─── Helper: find SingleSelect option ID by name (fuzzy) ─────
function findOptionId(field, labelValue) {
if (!field?.options) return null;
// Normalise: lowercase, strip leading "status:", "priority:", "type:"
const norm = (s) => s.toLowerCase().replace(/^(status|priority|type):/, '');
const target = norm(labelValue);
const opt = field.options.find(o => norm(o.name) === target);
return opt?.id || null;
}
// ─── Helper: map labels to field values ──────────────────────
function resolveStatus(labels) {
const names = labels.map(l => l.name);
if (names.some(n => n === 'status:in-progress')) return 'In Progress';
if (names.some(n => n === 'status:needs-review')) return 'In Review';
if (names.some(n => n === 'status:blocked')) return 'Blocked';
if (names.some(n => n === 'status:ready')) return 'Ready';
if (names.some(n => n === 'status:needs-more-info')) return 'Needs Info';
if (names.some(n => ['status: completed', 'status:completed', 'completed'].includes(n))) return 'Done';
return 'Todo';
}
function resolvePriority(labels) {
const names = labels.map(l => l.name);
if (names.some(n => n === 'priority:critical')) return 'Critical';
if (names.some(n => n === 'priority:important' || n === 'priority:high')) return 'High';
if (names.some(n => n === 'priority:normal' || n === 'priority:medium')) return 'Medium';
if (names.some(n => n === 'priority:minor' || n === 'priority:low')) return 'Low';
return null;
}
function resolveType(labels) {
const typeLabel = labels.find(l => l.name.startsWith('type:'));
if (!typeLabel) return null;
// Convert "type:bug" → "Bug", "type:feature" → "Feature", etc.
const raw = typeLabel.name.replace('type:', '');
return raw.charAt(0).toUpperCase() + raw.slice(1);
}
// ─── Helper: set a project field value ───────────────────────
async function setFieldValue(itemId, field, value) {
if (!field || !value) return false;
if (field.dataType === 'SINGLE_SELECT') {
// Try exact match first, then fuzzy
let optId = findOptionId(field, value);
if (!optId) {
// Try partial match
const norm = value.toLowerCase();
const opt = field.options.find(o => o.name.toLowerCase().includes(norm) || norm.includes(o.name.toLowerCase()));
optId = opt?.id || null;
}
if (!optId) {
core.info(` ⚠ No matching option "${value}" in ${field.name} field (options: ${field.options.map(o => o.name).join(', ')})`);
return false;
}
if (!dryRun) {
await gql(`
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}) { projectV2Item { id } }
}
`, { projectId, itemId, fieldId: field.id, optionId: optId });
}
return true;
}
if (field.dataType === 'TEXT') {
if (!dryRun) {
await gql(`
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $text: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { text: $text }
}) { projectV2Item { id } }
}
`, { projectId, itemId, fieldId: field.id, text: value });
}
return true;
}
return false;
}
// ─── Step 4: Process each open issue ─────────────────────────
for (const issue of issues) {
const labels = issue.labels || [];
const issueNumber = issue.number;
let itemId = existingItems.get(issueNumber);
// Add to project if not present
if (!itemId) {
if (!addMissing) {
report.skipped.push(`#${issueNumber} — not in project, add_missing=false`);
continue;
}
core.info(`Adding #${issueNumber} to project...`);
if (!dryRun) {
try {
const addResult = await gql(`
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {
projectId: $projectId
contentId: $contentId
}) { item { id } }
}
`, { projectId, contentId: issue.node_id });
itemId = addResult.addProjectV2ItemById.item.id;
report.added.push(`#${issueNumber}: ${issue.title.slice(0, 60)}`);
} catch (err) {
report.errors.push(`#${issueNumber} add failed: ${err.message}`);
continue;
}
} else {
report.added.push(`#${issueNumber} [DRY RUN]: ${issue.title.slice(0, 60)}`);
continue; // Can't set fields without real itemId in dry run
}
}
// Resolve target field values
const targetStatus = resolveStatus(labels);
const targetPriority = resolvePriority(labels);
const targetType = resolveType(labels);
let changed = false;
try {
if (statusField && targetStatus) {
const ok = await setFieldValue(itemId, statusField, targetStatus);
if (ok) changed = true;
}
if (priorityField && targetPriority) {
const ok = await setFieldValue(itemId, priorityField, targetPriority);
if (ok) changed = true;
}
if (typeField && targetType) {
const ok = await setFieldValue(itemId, typeField, targetType);
if (ok) changed = true;
}
if (changed) {
const summary = [
statusField && targetStatus ? `Status→${targetStatus}` : null,
priorityField && targetPriority ? `Priority→${targetPriority}` : null,
typeField && targetType ? `Type→${targetType}` : null,
].filter(Boolean).join(', ');
report.updated.push(`#${issueNumber}: ${summary}`);
}
} catch (err) {
report.errors.push(`#${issueNumber} field update failed: ${err.message}`);
}
}
// ─── Step 5: Summary report ──────────────────────────────────
const summary = [
`# Issue → Project Field Sync Report`,
`> ${dryRun ? '🔍 DRY RUN — no changes were made' : '✅ Changes applied'}`,
`> Project #${projectNumber} | Run: ${new Date().toISOString()}`,
'',
`## Added to Project (${report.added.length})`,
report.added.length > 0 ? report.added.map(r => `- ${r}`).join('\n') : '_None_',
'',
`## Fields Updated (${report.updated.length})`,
report.updated.length > 0 ? report.updated.map(r => `- ${r}`).join('\n') : '_None_',
'',
`## Skipped (${report.skipped.length})`,
report.skipped.length > 0 ? report.skipped.map(r => `- ${r}`).join('\n') : '_None_',
'',
`## Errors (${report.errors.length})`,
report.errors.length > 0 ? report.errors.map(r => `- ⚠️ ${r}`).join('\n') : '_None_',
].join('\n');
await core.summary.addRaw(summary).write();
core.info(summary);
if (report.errors.length > 0) {
core.setFailed(`Sync completed with ${report.errors.length} error(s).`);
}