#!/usr/bin/env node // Migrate Zube labels to GitHub Project Statuses const request = require('../../.github/workflows/scripts/request'); console.log('GH Issues: Zube > GitHub Project Migrator'); console.log('========================================='); console.log(''); const STATUS_MAP = { 'To Triage': 'To Triage', 'Backlog': 'Backlog', 'Icebox': 'Ice Box', 'Groomed': 'Groomed', 'Design Triage': 'To Triage', 'Backend Blocked': 'Backend Blocked', 'Next Up': 'Working', 'Reopened': 'Reopened', 'Working': 'Working', 'Review': 'Review', 'To Test': 'To Test', 'QA Review': 'QA Review', 'QA Next up': 'QA Working', 'QA Blocked': 'QA Blocked', 'QA Working': 'QA Working', 'Done': 'Done', }; // Options // -m = milestone - only migrate issues with the given milestone // -z = zube_status - only migrate issues with the given zube label // -p = project - GH project in the format 'org#number' // -a = apply - make the changes (default is dry-run that shows which changes will be made) // -s = size = sync estimates from size labels and not status // Parse the options // Arguments are after the process name and script name const options = { args: [...process.argv.slice(2)], milestone: undefined, zubeStatus: undefined, project: undefined, repo: undefined, apply: false, migrateSize: false, }; function handleOption(options, optName, flag, hasValue) { const provided = options.args.findIndex((item) => item === flag); if (provided >= 0) { // Remove the arg that was found options.args.splice(provided, 1); if (hasValue) { if (options.args.length < (provided + 1)) { console.log(`You need to provide a value for the ${ flag } option`); return; } else { options[optName] = options.args[provided]; options.args.splice(provided, 1); } } else { options[optName] = true; } } } handleOption(options, 'project', '-p', true); handleOption(options, 'repo', '-r', true); handleOption(options, 'milestone', '-m', true); handleOption(options, 'zubeStatus', '-z', true); handleOption(options, 'apply', '-a', false); handleOption(options, 'migrateSize', '-s', false); if (!process.env.TOKEN && !process.env.GH_TOKEN) { console.log('You must set a GitHub token in either the TOKEN or GH_TOKEN environment variables'); return; } if (!options.project) { console.log('You must provide a GitHub project with the -p flag in the form org#number'); return; } else { const pParts = options.project.split('#'); if (pParts.length !== 2) { console.log('GitHub project must be in the form org#number'); return; } options.project = pParts; } if (!options.repo) { console.log('You must provide a GitHub repository with the -r flag in the form org/name'); return; } else { const rParts = options.repo.split('/'); if (rParts.length !== 2) { console.log('GitHub repository must be in the form org/repo'); return; } options.repo = rParts; } if (!options.milestone) { console.log('You must provide a GitHub milestone with the -m flag'); return; } if (options.zubeStatus && !options.zubeStatus.startsWith('[zube')) { options.zubeStatus = `[zube]: ${ options.zubeStatus }`; } // console.log(options); async function syncStatus() { // Fetch the GitHub Project board const project = await request.ghProject(options.project[0], options.project[1]); if (!project || Object.keys(project).length === 0) { console.log('Unable to fetch ID for specified project'); return; } // console.log(project); console.log('Fetching issues...'); // Fetch all of the matching issues const issues = await request.ghFetchOpenIssues(options.repo[0], options.repo[1], options.milestone, options.zubeStatus); if (!issues) { console.log('Unable to fetch issues'); return; } console.log(`Fetched ${ issues.length } issue(s)`); console.log(''); // Process the issues and figure out which ones need updating const updates = []; issues.forEach((issue) => { const projectInfo = issue.projectItems.nodes.find((pItem) => pItem.project.id === project.id); // Determine the current issue state from the Zube label const labels = (issue.labels?.nodes || []).filter((label) => label.name.startsWith('[zube]: ')).map((label) => label.name); if (labels.length > 1) { console.log(`Warning: Issue ${ issue.number } has more than one Zube label - ignoring`); } else if (labels.length < 1) { console.log(`Warning: Issue ${ issue.number } does not have a Zube label - ignoring`); } else { const ghStatus = STATUS_MAP[labels[0].substr(8)]; const statusChange = projectInfo?.status?.name !== ghStatus; if (statusChange) { updates.push({ ...issue, idInProject: projectInfo?.id, currentStatus: projectInfo?.status?.name || 'No Status', status: ghStatus, statusChange, }); } } }); console.log(`#ISSUE ${ 'TITLE'.padEnd(80) } CHANGE NOTE`); console.log(`------ ${ '-'.padEnd(80, '-') } -------- ----`); updates.forEach((update) => { let change = '' let note = '' if (!update.idInProject) { change = 'ADD '; note = `${update.status}`; } else if (update.statusChange) { change = `UPDATE `; note = `${update.currentStatus} -> ${update.status}`; } const number = `${ update.number }`.padStart(6); console.log(`${ number } ${ update.title.substr(0,80).padEnd(80)} ${ change } ${ note }`); }); console.log(''); console.log(`${ updates.length} issue(s) require updating out of ${ issues.length }`); const add = updates.filter((update) => !update.idInProject); if (options.apply && add.length) { let gQL = 'mutation {\n'; // Add all of the missing items to the project in one go add.forEach((update) => { gQL += `issue${ update.number }: addProjectV2ItemById(input: {projectId: "${ project.id }" contentId: "${ update.id }"}) { item { id } }\n`; }); gQL += '}'; const res = await request.graphql(gQL); if (!res.data) { console.log('Error updating'); console.log(res); return; } if (res.data) { Object.keys(res.data).forEach((itemName) => { const itemRes = res.data[itemName]?.item; const number = parseInt(itemName.substr(5), 10); const existingUpdate = updates.find((update) => update.number === number); if (existingUpdate) { existingUpdate.statusChange = true; existingUpdate.idInProject = itemRes.id; } }); } } // Apply all of the status changes const statusChanges = updates.filter((update) => (update.idInProject && update.statusChange)); if (options.apply && statusChanges.length) { let statusQL = `mutation {\n`; statusChanges.forEach((update) => { if (update.idInProject && update.statusChange) { // Get the optionId for the new status const optionId = project.statusField.options[update.status]; if (!optionId) { console.log(`Warning: Can not find status ${ update.status } in the GitHub Project`); } else { statusQL += `issue${update.number}: updateProjectV2ItemFieldValue(input: { projectId: "${ project.id }" itemId: "${ update.idInProject }" fieldId: "${ project.statusField.id }" value: { singleSelectOptionId: "${ optionId }" } }) { projectV2Item { id } }\n` } } }); statusQL += '}'; const statusRes = await request.graphql(statusQL); if (!statusRes.data) { console.log('Error updating statuses of issues'); console.log(statusRes); return; } } if (!updates.length && !statusChanges.length) { console.log(''); console.log('All issues are up to date - nothing to do'); } else { console.log(''); if (options.apply) { console.log('Updates applied'); } else { console.log('To apply updates, run again with the -a flag'); } } console.log(''); } async function syncEstimate() { // Fetch the GitHub Project board const project = await request.ghProject(options.project[0], options.project[1]); if (!project || Object.keys(project).length === 0) { console.log('Unable to fetch ID for specified project'); return; } // console.log('Fetched project'); // console.log(project); if (!project.storyPointsField) { console.log('Error: Project does not have a Story Points field'); return; } console.log('Fetching issues...'); // Fetch all of the matching issues in the project const issues = await request.ghFetchOpenIssuesInProject(options.project[0], options.project[1], options.milestone, options.zubeStatus); if (!issues) { console.log('Unable to fetch issues'); return; } console.log(`Fetched ${ issues.length } issue(s)`); // Filter down to those with a size label const estimated = []; issues.forEach((issue) => { const sizeLabels = (issue.labels?.nodes || []).filter((label) => label.name.startsWith('size/')).map((label) => label.name); if (sizeLabels.length === 1) { estimated.push({ ...issue, estimate: parseInt(sizeLabels[0].split('/')[1]) }); } }); console.log(`${ estimated.length } issues have a size label`); const updates = []; estimated.forEach((issue) => { const projectInfo = issue.projectItems.nodes.find((pItem) => pItem.project.id === project.id); const prjEstimate = projectInfo.storyPoints?.number; let updating = false; let action = false; if (prjEstimate === undefined) { action = true; } else if (prjEstimate !== issue.estimate) { updating = true; action = true; } if (action) { updates.push({ ...issue, idInProject: projectInfo?.id, currentEstimate: projectInfo?.storyPoints?.number || 'No Estimate', estimate: issue.estimate, updating, }); } }); if (updates.length) { console.log(''); console.log('Updates required:'); console.log(''); console.log(`#ISSUE ${ 'TITLE'.padEnd(80) } CHANGE ESTIMATE`); console.log(`------ ${ '-'.padEnd(80, '-') } -------- --------`); } let updateQL = `mutation {\n`; updates.forEach((update) => { let change = ''; let note = ''; if (update.updating) { change = 'UPDATE '; note = `${update.currentEstimate} -> ${update.estimate}`; } else { change = `SET `; note = `${update.estimate}`; } updateQL += `issue${update.number}: updateProjectV2ItemFieldValue(input: { projectId: "${ project.id }" itemId: "${ update.idInProject }" fieldId: "${ project.storyPointsField.id }" value: { number: ${ update.estimate } } }) { projectV2Item { id } }\n`; const number = `${ update.number }`.padStart(6); console.log(`${ number } ${ update.title.substr(0,80).padEnd(80)} ${ change } ${ note }`); }); updateQL += '}'; if (!updates.length) { console.log(''); console.log('All issues are up to date - nothing to do'); } else { console.log(''); if (options.apply) { const updateRes = await request.graphql(updateQL); if (!updateRes.data) { console.log('Error updating estimates of issues'); console.log(updateRes); return; } console.log(`${ updates.length} updates applied`); } else { console.log(`${ updates.length} issue(s) require updating out of ${ estimated.length }`); console.log(''); console.log('To apply updates, run again with the -a flag'); } } console.log(''); } // Two modes if (!options.migrateSize) { syncStatus(); } else { syncEstimate(); }