Issue Release Field Scheduled Sync #321
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | |
| } |