From c116777dc3f7b149b0c76fbd6919ecc1cea5480e Mon Sep 17 00:00:00 2001 From: Neil MacDougall Date: Mon, 22 Apr 2024 16:34:24 +0100 Subject: [PATCH] Zube migration: Update script, update workflow and remove Zube workflow (#10855) --- .github/workflows/pr-zube.yaml | 18 -- .github/workflows/scripts/pr-gh-project.js | 163 +++++------- .github/workflows/scripts/pr.js | 273 --------------------- .github/workflows/scripts/request.js | 50 +++- scripts/github/zube-migration | 190 +++++++++++++- 5 files changed, 290 insertions(+), 404 deletions(-) delete mode 100644 .github/workflows/pr-zube.yaml delete mode 100644 .github/workflows/scripts/pr.js diff --git a/.github/workflows/pr-zube.yaml b/.github/workflows/pr-zube.yaml deleted file mode 100644 index d7f84d63d6..0000000000 --- a/.github/workflows/pr-zube.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: zube-integration -on: - pull_request_target: - types: [ opened, reopened, edited, closed ] - -jobs: - rancher_zube: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Use Node.js - uses: actions/setup-node@v1 - with: - node-version: '16.x' - - name: script - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node .github/workflows/scripts/pr.js diff --git a/.github/workflows/scripts/pr-gh-project.js b/.github/workflows/scripts/pr-gh-project.js index 301b158029..9eb9d1e8dd 100644 --- a/.github/workflows/scripts/pr-gh-project.js +++ b/.github/workflows/scripts/pr-gh-project.js @@ -6,19 +6,11 @@ const request = require('./request'); -const TRIAGE_LABEL = '[zube]: To Triage'; -const IN_REVIEW_LABEL = '[zube]: Review'; -const IN_TEST_LABEL = '[zube]: To Test'; -const DONE_LABEL = '[zube]: Done'; -const BACKEND_BLOCKED_LABEL = '[zube]: Backend Blocked'; -const QA_REVIEW_LABEL = '[zube]: QA Review'; const TECH_DEBT_LABEL = 'kind/tech-debt'; const DEV_VALIDATE_LABEL = 'status/dev-validate'; const QA_NONE_LABEL = 'QA/None'; const QA_DEV_AUTOMATION_LABEL = 'QA/dev-automation' -const GH_PRJ_TRIAGE = 'Triage'; - const GH_PRJ_TO_TEST = 'To Test'; const GH_PRJ_QA_REVIEW = 'QA Review'; const GH_PRJ_IN_REVIEW = 'Review'; @@ -78,34 +70,28 @@ function hasLabel(issue, label) { } async function moveIssueToProjectState(project, prjIssueID, issue, state) { - console.log(`moveIssueToProjectState ${ state }`); - console.log(JSON.stringify(project, null, 2)); - console.log(prjIssueID); + // console.log(`moveIssueToProjectState ${ state }`); + // console.log(JSON.stringify(project, null, 2)); + // console.log(prjIssueID); // console.log(JSON.stringify(issue, null, 2)); - const res = await request.ghUpdateProjectIssueStatus(project, prjIssueID, state); - console.log(JSON.stringify(res, null, 2)); - + // console.log(JSON.stringify(res, null, 2)); return res; } /** - * Remove Zube labels + * Remove all Zube labels from an issue */ -async function removeZubeLabels(issue, label) { - // Remove all Zube labels - let cleanLabels = labels.filter(l => l.name.indexOf('[zube]') === -1); +async function removeZubeLabels(issue) { + const currentLabels = issue.labels.map((v) => v.name); + let cleanLabels = issue.labels.filter(l => l.name.indexOf('[zube]') === -1); - cleanLabels = cleanLabels.map((v) => { - return v.name; - }); + cleanLabels = cleanLabels.map((v) => v.name()); // Turn the array of labels into just their names - console.log(` Current Labels: ${cleanLabels}`); - - // Add the 'to test' label - cleanLabels.push(label); + console.log(' Removing Zube labels:'); + console.log(` Current Labels: ${currentLabels}`); console.log(` New Labels : ${cleanLabels}`); // Update the labels @@ -113,27 +99,6 @@ async function removeZubeLabels(issue, label) { return request.put(labelsAPI, { labels: cleanLabels }); } -async function waitForLabel(issue, label) { - let tries = 0; - while (!hasLabel(issue, label) && tries < 10) { - console.log(` Waiting for issue to have the label ${label} (${tries})`); - - // Wait 10 seconds - await new Promise(r => setTimeout(r, 10000)); - - // Refetch the issue - issue = await request.fetch(issue.url); - - tries++; - } - - if (tries > 10) { - console.log('WARNING: Timed out waiting for issue to have the Done label'); - } else { - console.log(' Issue has the done label'); - } -} - async function processClosedAction() { const pr = event.pull_request; const body = pr.body; @@ -147,8 +112,6 @@ async function processClosedAction() { return; } - console.log(JSON.stringify(ghProject, null, 2)); - console.log('======'); console.log('Processing Closed PR #' + pr.number + ' : ' + pr.title); console.log('======'); @@ -202,55 +165,52 @@ async function processClosedAction() { if (hasLabel(iss, TECH_DEBT_LABEL) || hasLabel(iss, DEV_VALIDATE_LABEL) || hasLabel(iss, QA_NONE_LABEL)) { console.log(' Issue is tech debt/dev validate/qa none - ignoring'); } else { - // Put this in when we remove the Zube workflow - // A single workflow needs to re-open the issue after GH closes it - // console.log(' Waiting for Zube to mark the issue as done ...'); + // Re-open the issue after GH closes it + await new Promise(r => setTimeout(r, 2500)); - // // Output labels - // const labels = iss.labels || []; - - // console.log(labels.join(', ')); - - // // console.log(JSON.stringify(iss, null, 2)); - - // // The Zube Integration will label the issue with the Done label - // // Since it runs via a webhook, it should have done that well before our GitHub action - // // is scheduled and has run, but we will check it has the label and wait if not - // await waitForLabel(iss, DONE_LABEL); - - // // Wait - // await new Promise(r => setTimeout(r, 10000)); - // // Re-open the issue if it is closed - // if (iss.state === 'closed') { - // console.log(' Re-opening issue'); - // await request.patch(detail, { state: 'open' }); - // } else { - // console.log(' Expecting issue to be closed, but it is not'); - // } - - // console.log(' Waiting for Zube to mark the issue as in triage ...'); - - // // The Zube Integration will label the issue as To Triage now that is has been re-opened - // // Wait for that and then we can move it to test - // await waitForLabel(iss, TRIAGE_LABEL); - - // Move to QA Review if the issue has the label that dev wrote automated tests - if (hasLabel(iss, QA_DEV_AUTOMATION_LABEL)) { - console.log(' Updating GitHub Project to move issue to QA Review'); - - console.log(JSON.stringify(iss, null, 2)); - - // await moveIssueToProjectState(iss, GH_PRJ_QA_REVIEW); - // Uncomment when we switch off Zube - // await removeZubeLabels(iss); + // Re-open the issue if it is closed (it should be) + if (iss.state === 'closed') { + console.log(' Re-opening issue'); + await request.patch(detail, { state: 'open' }); } else { - console.log(' Updating GitHub Project to move issue to Test'); + console.log(' Expecting issue to be closed, but it is not'); + } - console.log(JSON.stringify(iss, null, 2)); + // Need to fetch the issue project status + const info = parseOrgAndRepo(iss.repository_url); + let prjIssue = await request.ghProjectIssue(info.org, info.repo, i); - // await moveIssueToProjectState(iss, GH_PRJ_TO_TEST); - // Uncomment when we switch off Zube - // await removeZubeLabels(iss); + // Is the issue on the board? + if (!prjIssue?.[ghProject.id]) { + // Issue is not on the board + console.log(`Issue ${ i } is NOT on the project board - adding it ...`); + + await request.ghAddIssueToProject(ghProject, iss); + + prjIssue = await request.ghProjectIssue(info.org, info.repo, i); + + if (!prjIssue?.[ghProject.id]) { + console.log("Error: Could not add issue to Project Board"); + console.log(prjIssue); + } else { + console.log('Added issue to the project board'); + console.log(JSON.stringify(prjIssue, null, 2)); + } + } + + if (prjIssue?.[ghProject.id]) { + // Move to QA Review if the issue has the label that dev wrote automated tests + if (hasLabel(iss, QA_DEV_AUTOMATION_LABEL)) { + console.log(' Updating GitHub Project to move issue to QA Review'); + + await moveIssueToProjectState(ghProject, prjIssue[ghProject.id], iss, GH_PRJ_QA_REVIEW); + await removeZubeLabels(iss); + } else { + console.log(' Updating GitHub Project to move issue to Test'); + + await moveIssueToProjectState(ghProject, prjIssue[ghProject.id], iss, GH_PRJ_TO_TEST); + await removeZubeLabels(iss); + } } } @@ -288,8 +248,6 @@ async function processOpenOrEditAction() { return; } - - console.log(JSON.stringify(ghProject, null, 2)); const pr = event.pull_request; const body = pr.body; @@ -309,19 +267,15 @@ async function processOpenOrEditAction() { console.log('Processing Issue #' + i + ' - ' + iss.title); if (pr.draft) { - console.log(' Issue will not be moved to In Review (Draft PR)'); - } else if (hasLabel(iss, BACKEND_BLOCKED_LABEL)) { - console.log(' Issue will not be moved to In Review (Backend Blocked)'); + console.log(' Issue will not be moved to Review (Draft PR)'); + // TODO: + // } else if (hasLabel(iss, BACKEND_BLOCKED_LABEL)) { + // console.log(' Issue will not be moved to Review (Backend Blocked)'); } else { // Need to fetch the issue project status const info = parseOrgAndRepo(iss.repository_url); let prjIssue = await request.ghProjectIssue(info.org, info.repo, i); - // console.log(info); - // console.log('-------- GH ISSUE -----'); - // console.log(JSON.stringify(prjIssue, null, 2)); - // console.log('---------'); - // Is the issue on the board? if (!prjIssue?.[ghProject.id]) { // Issue is not on the board @@ -342,6 +296,7 @@ async function processOpenOrEditAction() { if (prjIssue?.[ghProject.id]) { await moveIssueToProjectState(ghProject, prjIssue[ghProject.id], iss, GH_PRJ_IN_REVIEW); + await removeZubeLabels(iss); } else { console.log(`Can not move issue to state ${ GH_PRJ_IN_REVIEW } - issue is not on the board`); } @@ -371,7 +326,7 @@ async function processOpenOrEditAction() { } // Debugging -console.log(JSON.stringify(event, null, 2)); +// console.log(JSON.stringify(event, null, 2)); // Look at the action if (event.action === 'opened' || event.action === 'reopened') { diff --git a/.github/workflows/scripts/pr.js b/.github/workflows/scripts/pr.js deleted file mode 100644 index f1934e7b7b..0000000000 --- a/.github/workflows/scripts/pr.js +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env node - -const request = require('./request'); - -const TRIAGE_LABEL = '[zube]: To Triage'; -const IN_REVIEW_LABEL = '[zube]: Review'; -const IN_TEST_LABEL = '[zube]: To Test'; -const DONE_LABEL = '[zube]: Done'; -const BACKEND_BLOCKED_LABEL = '[zube]: Backend Blocked'; -const QA_REVIEW_LABEL = '[zube]: QA Review'; -const TECH_DEBT_LABEL = 'kind/tech-debt'; -const DEV_VALIDATE_LABEL = 'status/dev-validate'; -const QA_NONE_LABEL = 'QA/None'; -const QA_DEV_AUTOMATION_LABEL = 'QA/dev-automation' - -// The event object -const event = require(process.env.GITHUB_EVENT_PATH); - -function getReferencedIssues(body) { - // https://docs.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword - const regexp = /[Ff]ix(es|ed)?\s*#([0-9]*)|[Cc]lose(s|d)?\s*#([0-9]*)|[Rr]esolve(s|d)?\s*#([0-9]*)/g; - var v; - const issues = []; - do { - v = regexp.exec(body); - if (v) { - issues.push(parseInt(v[2], 10)); - } - } while (v); - return issues; -} - -function hasLabel(issue, label) { - const labels = issue.labels || []; - - return !!(labels.find(l =>l.name.toLowerCase() === label.toLowerCase())); -} - -function removeZubeLabels(labels) { - return labels.filter(l => l.name.indexOf('[zube]') === -1); -} - -async function resetZubeLabels(issue, label) { - // Remove all Zube labels - let cleanLabels = removeZubeLabels(issue.labels); - - cleanLabels = cleanLabels.map((v) => { - return v.name; - }); - - // Turn the array of labels into just their names - console.log(` Current Labels: ${cleanLabels}`); - - // Add the 'to test' label - cleanLabels.push(label); - console.log(` New Labels : ${cleanLabels}`); - - // Update the labels - const labelsAPI = `${issue.url}/labels`; - return request.put(labelsAPI, {labels: cleanLabels}); -} - -async function waitForLabel(issue, label) { - let tries = 0; - while (!hasLabel(issue, label) && tries < 10) { - console.log(` Waiting for issue to have the label ${label} (${tries})`); - - // Wait 10 seconds - await new Promise(r => setTimeout(r, 10000)); - - // Refetch the issue - issue = await request.fetch(issue.url); - - tries++; - } - - if (tries > 10) { - console.log('WARNING: Timed out waiting for issue to have the Done label'); - } else { - console.log(' Issue has the done label'); - } -} - -async function processClosedAction() { - const pr = event.pull_request; - const body = pr.body; - - console.log('======'); - console.log('Processing Closed PR #' + pr.number + ' : ' + pr.title); - console.log('======'); - - // Check that the issue was merged and not just closed - if (!pr.merged) { - console.log( ' PR was closed without merging - ignoring'); - return; - } - - const issues = getReferencedIssues(body); - if (issues.length > 0) { - console.log(' This PR fixes issues: ' + issues.join(', ')); - } else { - console.log(" This PR does not fix any issues"); - return; - } - - // Need to get all open PRs to see if any other references the same issues that this PR says it fixes - const openPRs = event.repository.url + '/pulls?state=open&per_page=100'; - const r = await request.fetch(openPRs); - const issueMap = issues.reduce((prev, issue) => { prev[issue] = true; return prev; }, {}) - - // Go through all of the Open PRs and see if they fix any of the same issues that this PR does - // If not, then the issue has been completed, so we can process it - r.forEach(openPR => { - const fixed = getReferencedIssues(openPR.body); - fixed.forEach(issue => issueMap[issue] = false); - }); - - // Filter down the list of issues that should be closed because this PR was merged - const fixed = Object.keys(issueMap).filter(key => !!issueMap[key]); - console.log(''); - - Object.keys(issueMap).forEach(k => { - if (k && !issueMap[k]) { - console.log(` Issue #${k} will be ignored as another open PR also states that it fixes this issue`); - } - }); - - // GitHub will do the closing, so we expect each issue to already be closed - // We will fetch each issue in turn, expecting it to be closed - // We will re-open the issue and label it as ready to test - fixed.forEach(async(i) => { - const detail = event.repository.url + '/issues/' + i; - const iss = await request.fetch(detail); - console.log('') - console.log('Processing Issue #' + i + ' - ' + iss.title); - - // If the issue is a tech debt issue or says dev will validate then don't move it to 'To Test' - if(hasLabel(iss, TECH_DEBT_LABEL) || hasLabel(iss, DEV_VALIDATE_LABEL) || hasLabel(iss, QA_NONE_LABEL)) { - console.log(' Issue is tech debt/dev validate/qa none - ignoring'); - } else { - console.log(' Waiting for Zube to mark the issue as done ...'); - - // Output labels - const labels = iss.labels || []; - - console.log(labels.join(', ')); - - // console.log(JSON.stringify(iss, null, 2)); - - // The Zube Integration will label the issue with the Done label - // Since it runs via a webhook, it should have done that well before our GitHub action - // is scheduled and has run, but we will check it has the label and wait if not - await waitForLabel(iss, DONE_LABEL); - - // Wait - await new Promise(r => setTimeout(r, 10000)); - // Re-open the issue if it is closed - if (iss.state === 'closed') { - console.log(' Re-opening issue'); - await request.patch(detail, { state: 'open' }); - } else { - console.log(' Expecting issue to be closed, but it is not'); - } - - console.log(' Waiting for Zube to mark the issue as in triage ...'); - - // The Zube Integration will label the issue as To Triage now that is has been re-opened - // Wait for that and then we can move it to test - await waitForLabel(iss, TRIAGE_LABEL); - - // Move to QA Review if the issue has the label that dev wrote automated tests - if (hasLabel(iss, QA_DEV_AUTOMATION_LABEL)) { - console.log(' Updating labels to move issue to QA Review'); - - await resetZubeLabels(iss, QA_REVIEW_LABEL); - } else { - console.log(' Updating labels to move issue to Test'); - - await resetZubeLabels(iss, IN_TEST_LABEL); - } - } - - console.log(''); - }); -} - -async function processOpenAction() { - const pr = event.pull_request; - - // Check that an assignee has been set - if (pr.assignees.length === 0) { - console.log('======'); - console.log('Processing Opened PR #' + pr.number + ' : ' + pr.title); - console.log('======'); - - console.log(` Adding assignee to the PR: ${pr.user.login}`); - - // Update the assignees - const assigneesAPI = `${event.repository.url}/issues/${pr.number}/assignees`; - await request.post(assigneesAPI, {assignees: [pr.user.login]}); - } -} - -async function processOpenOrEditAction() { - console.log('======'); - console.log('Processing Opened/Edited PR #' + event.pull_request.number + ' : ' + event.pull_request.title); - console.log('======'); - - const pr = event.pull_request; - const body = pr.body; - const issues = getReferencedIssues(body); - if (issues.length > 0) { - console.log('+ This PR fixes issues: #' + issues.join(', ')); - } else { - console.log("+ This PR does not fix any issues"); - } - - const milestones = {}; - - for (i of issues) { - const detail = `${event.repository.url}/issues/${i}`; - const iss = await request.fetch(detail); - console.log('') - console.log('Processing Issue #' + i + ' - ' + iss.title); - - if (pr.draft) { - console.log(' Issue will not be moved to In Review (Draft PR)'); - } else if (hasLabel(iss, BACKEND_BLOCKED_LABEL)) { - console.log(' Issue will not be moved to In Review (Backend Blocked)'); - } else { - if (!hasLabel(iss, IN_REVIEW_LABEL)) { - // Add the In Review label to the issue as it does not have it - await resetZubeLabels(iss, IN_REVIEW_LABEL); - } else { - console.log(' Issue already has the In Review label'); - } - } - - if (iss.milestone) { - milestones[iss.milestone.title] = iss.milestone.number; - } - } - - const keys = Object.keys(milestones); - if (keys.length === 0) { - console.log('No milestones on issue(s) for this PR'); - } else if (keys.length > 1) { - console.log('More than one milestone on issues for this PR'); - } else { - // There is exactly 1 milestone, so use that for the PR - const milestoneNumber = milestones[keys[0]]; - - if (event.pull_request.milestone?.number !== milestoneNumber) { - console.log('Updating PR with milestone: ' + keys[0]); - await request.patch(event.pull_request.issue_url, {milestone: milestoneNumber}); - } else { - console.log('PR is already assigned to milestone ' + keys[0]); - } - } -} - -// Debugging -// console.log(JSON.stringify(event, null, 2)); - -// Look at the action -if (event.action === 'opened' || event.action === 'reopened') { - processOpenAction(); - processOpenOrEditAction(); -} else if (event.action === 'edited') { - processOpenOrEditAction(); -} else if (event.action === 'closed') { - processClosedAction(); -} diff --git a/.github/workflows/scripts/request.js b/.github/workflows/scripts/request.js index fceab552b4..ff8ba0b2b8 100644 --- a/.github/workflows/scripts/request.js +++ b/.github/workflows/scripts/request.js @@ -34,12 +34,18 @@ async function ghProject(org, num) { name } } + ... on ProjectV2FieldCommon { + id + name + } } } } } }`; + // console.log(gQL); + const res = await graphql(gQL); const prj = {}; @@ -62,13 +68,21 @@ async function ghProject(org, num) { options: optionMap }; } + + const storyPointsField = v2Project.fields?.nodes.find((node) => node.name === 'Story Points'); + + if (storyPointsField) { + prj.storyPointsField = { + id: storyPointsField.id + }; + } + } else { console.log(res); } return prj; } - /** * Fetch the issue and get the project info for the issue (map of project ID to project issue ID) * @@ -187,6 +201,24 @@ async function ghFetchOpenIssues(org, repo, milestone, label, previous) { extra += ` label:\\"${label}\\"`; } + const query = `repo:${org}/${repo} ${extra}`; + + return ghQueryIssues(query, previous); +} + +async function ghFetchOpenIssuesInProject(org, projectId, milestone, label, previous) { + let extra = milestone ? `milestone:${ milestone }` : ''; + + if (label) { + extra += ` label:\\"${label}\\"`; + } + + const query = `project:${org}/${projectId} ${extra}`; + + return ghQueryIssues(query, previous); +} + +async function ghQueryIssues(query, previous) { let after = ''; if (previous && previous.pageInfo?.endCursor) { @@ -194,7 +226,7 @@ async function ghFetchOpenIssues(org, repo, milestone, label, previous) { } const gQL = `query { - search(first:100, ${after} type:ISSUE, query:"is:open is:issue repo:${org}/${repo} ${extra}") { + search(first:100, ${after} type:ISSUE, query:"is:open is:issue ${query}") { issueCount, pageInfo { startCursor @@ -225,6 +257,11 @@ async function ghFetchOpenIssues(org, repo, milestone, label, previous) { id } } + storyPoints: fieldValueByName(name: "Story Points") { + ...on ProjectV2ItemFieldNumberValue { + number + } + } } } } @@ -244,7 +281,7 @@ async function ghFetchOpenIssues(org, repo, milestone, label, previous) { } if (res.data.search.pageInfo.hasNextPage) { - return await ghFetchOpenIssues(org, repo, milestone, label, res.data.search) + return await ghQueryIssues(query, res.data.search) } return res.data.search.nodes; @@ -319,7 +356,11 @@ function write(url, data, method) { response.on('end', () => { let response_body = Buffer.concat(chunks_of_data); - resolve(JSON.parse(response_body.toString())); + try { + resolve(JSON.parse(response_body.toString())); + } catch (e) { + reject(response); + } }); response.on('error', (error) => { @@ -342,4 +383,5 @@ module.exports = { ghUpdateProjectIssueStatus, ghFetchOpenIssues, ghAddIssueToProject, + ghFetchOpenIssuesInProject, }; diff --git a/scripts/github/zube-migration b/scripts/github/zube-migration index 84abe35f84..7b22a1292b 100755 --- a/scripts/github/zube-migration +++ b/scripts/github/zube-migration @@ -9,9 +9,22 @@ console.log('========================================='); console.log(''); const STATUS_MAP = { - 'Backlog': 'Backlog', '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 @@ -19,6 +32,7 @@ const STATUS_MAP = { // -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 @@ -30,6 +44,7 @@ const options = { project: undefined, repo: undefined, apply: false, + migrateSize: false, }; function handleOption(options, optName, flag, hasValue) { @@ -58,8 +73,9 @@ 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) { +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; @@ -85,6 +101,16 @@ 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) { @@ -99,7 +125,7 @@ if (options.zubeStatus && !options.zubeStatus.startsWith('[zube')) { // console.log(options); -async function run() { +async function syncStatus() { // Fetch the GitHub Project board const project = await request.ghProject(options.project[0], options.project[1]); @@ -111,8 +137,10 @@ async function run() { // console.log(project); + console.log('Fetching issues...'); + // Fetch all of the matching issues - const issues = await request.ghFetchOpenIssues('rancher', 'dashboard', options.milestone, options.zubeStatus); + const issues = await request.ghFetchOpenIssues(options.repo[0], options.repo[1], options.milestone, options.zubeStatus); if (!issues) { console.log('Unable to fetch issues'); @@ -272,4 +300,156 @@ async function run() { console.log(''); } -run(); +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(); +}