Skip to content

Issue Release Field Scheduled Sync #321

Issue Release Field Scheduled Sync

Issue Release Field Scheduled Sync #321

name: Issue Release Field Scheduled Sync
on:
schedule:
- cron: '0 * * * *'
workflow_dispatch:
jobs:
sync-release-fields:
runs-on: ubuntu-latest
permissions:
issues: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore priority cache
id: cache-priority
uses: actions/cache@v4
with:
path: .github/priority-cache.json
key: priority-cache-${{ github.run_id }}
restore-keys: |
priority-cache-
- name: Sync release fields for all project issues
uses: actions/github-script@v7
with:
github-token: ${{ secrets.PROJECT_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const fs = require('fs');
const path = require('path');
// Only operate on this specific project
const TARGET_PROJECT_ID = 'PVT_kwDOAp2shc4AiNzl';
console.log('Starting scheduled release field sync...');
try {
// Load previous priority cache
const cacheFile = '.github/priority-cache.json';
let previousPriorities = {};
if (fs.existsSync(cacheFile)) {
const cacheContent = fs.readFileSync(cacheFile, 'utf8');
previousPriorities = JSON.parse(cacheContent);
console.log(`Loaded ${Object.keys(previousPriorities).length} cached priority values`);
} else {
console.log('No previous priority cache found, treating all as new');
}
// Current priorities (will be saved at the end)
const currentPriorities = {};
// Read VERSION file to get current release
const versionContent = fs.readFileSync('VERSION', 'utf8').trim();
const versionParts = versionContent.split('.');
const currentRelease = `${versionParts[0]}.${versionParts[1]}`;
console.log(`Current release from VERSION: ${currentRelease}`);
// Query the target project directly
console.log(`Querying project: ${TARGET_PROJECT_ID}`);
const projectQuery = `
query($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
title
items(first: 100) {
nodes {
id
content {
__typename
... on Issue {
number
repository {
owner { login }
name
}
}
... on PullRequest {
number
}
}
fieldValues(first: 20) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name
field {
... on ProjectV2SingleSelectField {
id
name
}
}
}
... on ProjectV2ItemFieldTextValue {
text
field {
... on ProjectV2Field {
id
name
}
}
}
}
}
}
}
fields(first: 20) {
nodes {
... on ProjectV2Field {
id
name
dataType
}
... on ProjectV2SingleSelectField {
id
name
dataType
options {
id
name
}
}
}
}
}
}
}
`;
const projectResult = await github.graphql(projectQuery, {
projectId: TARGET_PROJECT_ID
});
const project = projectResult.node;
if (!project) {
throw new Error(`Project with ID ${TARGET_PROJECT_ID} not found`);
}
const projectTitle = project.title;
const projectItems = project.items?.nodes || [];
const projectFields = project.fields?.nodes || [];
console.log(`Found project: ${projectTitle}`);
console.log(`Found ${projectItems.length} items in project`);
// Get Release field information
const releaseField = projectFields.find(f => f.name === 'Release');
if (!releaseField) {
throw new Error(`Release field not found in project "${projectTitle}"`);
}
const releaseFieldId = releaseField.id;
const releaseFieldType = releaseField.dataType;
const releaseFieldOptions = releaseField.options || [];
console.log(`Release field type: ${releaseFieldType}`);
let updatedCount = 0;
let skippedCount = 0;
let errorCount = 0;
// Process each item in the project
for (const projectItem of projectItems) {
const itemId = projectItem.id;
const content = projectItem.content;
// Skip if no content (item was deleted)
if (!content) {
continue;
}
// Only process issues (skip PRs, draft issues, etc.)
if (content.__typename !== 'Issue') {
continue;
}
const issueNumber = content.number;
const issueOwner = content.repository.owner.login;
const issueRepo = content.repository.name;
// Skip if issue is not from this repository
if (issueOwner !== owner || issueRepo !== repo) {
continue;
}
console.log(`\nChecking issue #${issueNumber}`);
try {
// Find Priority and Release field values
let priorityValue = null;
let currentReleaseValue = null;
for (const fieldValue of projectItem.fieldValues.nodes) {
if (fieldValue.field?.name === 'Priority') {
priorityValue = fieldValue.name;
console.log(` Found Priority: ${priorityValue}`);
}
if (fieldValue.field?.name === 'Release') {
currentReleaseValue = fieldValue.text || fieldValue.name;
console.log(` Found Release: ${currentReleaseValue}`);
}
}
// Skip if no Priority value
if (!priorityValue) {
console.log(` No Priority value found, skipping`);
skippedCount++;
continue;
}
// Store current priority for this issue
const cacheKey = `${TARGET_PROJECT_ID}:${issueNumber}`;
currentPriorities[cacheKey] = priorityValue;
// Check if Priority has changed since last run
const previousPriority = previousPriorities[cacheKey];
const priorityChanged = previousPriority !== priorityValue;
if (!priorityChanged) {
console.log(` Priority unchanged (${priorityValue}), skipping Release update (preserving manual changes)`);
continue;
}
console.log(` Priority changed: "${previousPriority || '(new)'}" → "${priorityValue}"`);
// Determine what the release value should be based on priority
let expectedReleaseValue;
if (priorityValue === '0' || priorityValue === '1') {
expectedReleaseValue = currentRelease;
} else if (priorityValue.match(/^[2-9]$/) || priorityValue.match(/^\d{2,}$/)) {
expectedReleaseValue = 'Backlog';
} else {
console.log(` Unknown priority format: ${priorityValue}, skipping`);
continue;
}
console.log(` Updating Release field to: "${expectedReleaseValue}"`);
// Find the option ID for the expected release value
const option = releaseFieldOptions.find(o => o.name === expectedReleaseValue);
if (!option) {
console.log(` ERROR: Option "${expectedReleaseValue}" not found in Release field options`);
console.log(` Available options: ${releaseFieldOptions.map(o => o.name).join(', ')}`);
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `⚠️ **Release Automation Error**: Cannot set Release to "${expectedReleaseValue}" - this option does not exist in the project.\n\nAvailable options: ${releaseFieldOptions.map(o => o.name).join(', ')}\n\nPlease add "${expectedReleaseValue}" as an option to the Release field in your project.`
});
errorCount++;
continue;
}
console.log(` Using option ID: ${option.id} for "${expectedReleaseValue}"`);
// Update the Release field with the single select option ID
const updateMutation = `
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}
) {
projectV2Item {
id
}
}
}
`;
await github.graphql(updateMutation, {
projectId: TARGET_PROJECT_ID,
itemId: itemId,
fieldId: releaseFieldId,
optionId: option.id
});
console.log(` ✓ Successfully updated Release field to "${expectedReleaseValue}"`);
updatedCount++;
} catch (error) {
console.error(`Error processing issue #${issueNumber}:`, error.message);
errorCount++;
// Continue with next issue rather than failing the entire workflow
}
}
// Save current priorities cache for next run
const cacheDir = '.github';
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
fs.writeFileSync(cacheFile, JSON.stringify(currentPriorities, null, 2));
console.log(`\nSaved ${Object.keys(currentPriorities).length} priority values to cache`);
// Summary
console.log('\n=== Sync Summary ===');
console.log(`Total items in project: ${projectItems.length}`);
console.log(`Issues updated: ${updatedCount}`);
console.log(`Issues skipped (no Priority or unchanged): ${skippedCount}`);
console.log(`Errors encountered: ${errorCount}`);
} catch (error) {
console.error('Fatal error in scheduled sync:', error);
throw error;
}