diff --git a/.github/workflows/test-max-opened-issues.yml b/.github/workflows/test-max-opened-issues.yml new file mode 100644 index 000000000..7f6571927 --- /dev/null +++ b/.github/workflows/test-max-opened-issues.yml @@ -0,0 +1,99 @@ +name: "Test - Max opened issues" + +on: +# pull_request: {} + workflow_dispatch: {} + +env: + AWS_REGION: us-east-2 + +# Permissions required for assuming AWS identity +permissions: + id-token: write + contents: read + issues: write + +jobs: + setup: + runs-on: ubuntu-latest + steps: + - name: Setup + run: echo "Do setup" + + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + + - name: Upload Artifacts + uses: actions/upload-artifact@v3 + with: + name: metadata + path: ./tests/fixtures/metadata + retention-days: 1 + + test: + runs-on: ubuntu-latest + continue-on-error: true + needs: [setup] + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + + - name: Plan Atmos Component + id: current + uses: ./ + with: + max-opened-issues: '3' + labels: "test-max-opened-issues" + token: ${{ secrets.PUBLIC_REPO_ACCESS_TOKEN }} + + outputs: + result: ${{ steps.current.outcome }} + + assert: + runs-on: ubuntu-latest + needs: [test] + steps: + - uses: nick-fields/assert-action@v1 + with: + expected: 'success' + actual: "${{ needs.test.outputs.result }}" + + - name: Find all issues + id: issues + uses: lee-dohm/select-matching-issues@v1 + with: + query: 'label:test-max-opened-issues is:open' + token: ${{ github.token }} + + - name: Close found issues + id: test + run: | + cat ${{ steps.issues.outputs.path }} + echo "count=$(cat ${{ steps.issues.outputs.path }} | xargs -I {} -d '\n' echo "{}" | wc -l )" >> $GITHUB_OUTPUT + + - uses: nick-fields/assert-action@v1 + with: + expected: '3' + actual: "${{ steps.test.outputs.count }}" + + teardown: + runs-on: ubuntu-latest + needs: [assert] + if: ${{ always() }} + steps: + - name: Tear down + run: echo "Do Tear down" + + - name: Find all issues + id: issues + uses: lee-dohm/select-matching-issues@v1 + with: + query: 'label:test-max-opened-issues is:open' + token: ${{ github.token }} + + - name: Close found issues + run: cat ${{ steps.issues.outputs.path }} | xargs -I {} -d '\n' gh issue close {} + env: + GH_TOKEN: ${{ github.token }} diff --git a/action.yml b/action.yml index a76e2f2f1..5e765caea 100644 --- a/action.yml +++ b/action.yml @@ -21,6 +21,10 @@ inputs: description: "Comma-separated list of additional labels to assign issues to." required: false default: "" + process-all: + description: "Process all issues or only the ones that relates to affected stacks. Default: false" + required: false + default: "false" token: description: Used to pull node distributions for Atmos from Cloud Posse's GitHub repository. Since there's a default, this is typically diff --git a/src/action.js b/src/action.js index 17d9baa50..e16dc0a77 100644 --- a/src/action.js +++ b/src/action.js @@ -1,405 +1,218 @@ const fs = require('fs'); const core = require('@actions/core'); const artifact = require('@actions/artifact'); -const github = require('@actions/github'); - -const downloadArtifacts = async (artifactName) => { - try { - const artifactClient = artifact.create() - const downloadDirectory = '.' - - // Downloading the artifact - const downloadResponse = await artifactClient.downloadArtifact(artifactName, downloadDirectory); - - core.info(`Artifact ${artifactName} downloaded to ${downloadResponse.downloadPath}`); - } catch (error) { - throw new Error(`Failed to download artifacts: ${error.message}`); - } +const {StackFromIssue, getMetadataFromIssueBody} = require("./models/stacks_from_issues"); +const {Skip} = require("./operations/skip"); +const {Update} = require("./operations/update"); +const {Close} = require("./operations/close"); +const {Remove} = require("./operations/remove"); +const {Create} = require("./operations/create"); +const {Nothing} = require("./operations/nothing"); +const {StackFromArchive} = require("./models/stacks_from_archive"); + + +const downloadArtifacts = (artifactName) => { + const artifactClient = artifact.create() + const downloadDirectory = '.' + + // Downloading the artifact + return artifactClient.downloadArtifact(artifactName, downloadDirectory) + .then((item) => { + core.info(`Artifact ${artifactName} downloaded to ${item.downloadPath}`); + return item.downloadPath + }) }; -const getMetadataFromIssueBody = (body) => { - const regex = /```json\s([\s\S]+?)\s```/; - const matched = body.match(regex); +const mapOpenGitHubIssuesToComponents = async (octokit, context, labels) => { + const repository = context.repo; - if (matched && matched[1]) { - return JSON.parse(matched[1]); - } else { - throw new Error("Invalid metadata in the issue description"); - } - } + const listIssues = async (per_page, page, result) => { + const response = await octokit.rest.issues.listForRepo({ + ...repository, + state: 'open', + labels: labels, + per_page, + page + }); -const mapOpenGitHubIssuesToComponents = async (octokit, context) => { - const repository = context.repo; - - let per_page = 100; // Max allowed value per page - let page = 1; - let componentsToIssues = {}; - let componentsToMetadata = {}; - let isContinue = true; - - while (isContinue) { - const response = await octokit.rest.issues.listForRepo({ - ...repository, - state: 'open', - per_page, - page - }); - - if (response.data.length === 0) { - isContinue = false; - } else { - const driftDetectionIssues = response.data - .filter(issue => issue.title.startsWith('Drift Detected in') || issue.title.startsWith('Failure Detected in')); - - for (let issue of driftDetectionIssues) { - const metadata = getMetadataFromIssueBody(issue.body); - const slug = `${metadata.stack}-${metadata.component}` - componentsToIssues[slug] = { - number: issue.number, - error: issue.title.startsWith('Failure Detected in') - }; - componentsToMetadata[slug] = metadata; - } - - page++; - } + if (response.data.length === 0) { + return result } - return { - componentsToIssues: componentsToIssues, - componentsToMetadata: componentsToMetadata, - }; -} + const driftDetectionIssues = response.data.filter( + issue => issue.title.startsWith('Drift Detected in') || issue.title.startsWith('Failure Detected in') + ).filter( + issue => getMetadataFromIssueBody(issue.body) !== null + ); -const readMetadataFromPlanArtifacts = async () => { - const files = fs.readdirSync('.'); - const metadataFiles = files.filter(file => file.endsWith('metadata.json')); + const result_partition = driftDetectionIssues.map(issue => { + return new StackFromIssue(issue); + }) - let componentsToState = {}; - let componentsToMetadata = {}; - - for (let i = 0; i < metadataFiles.length; i++) { - const metadata = JSON.parse(fs.readFileSync(metadataFiles[i], 'utf8')); - - const slug = `${metadata.stack}-${metadata.component}`; + return await listIssues(per_page, page + 1, result.concat(result_partition)) + } - componentsToState[slug] = { - drifted: metadata.drifted, - error: metadata.error - }; - componentsToMetadata[slug] = metadata; + let per_page = 100; // Max allowed value per page + let result = await listIssues(per_page, 1, []) + return new Map(result.map( + (stackFromIssue) => { + return [stackFromIssue.slug, stackFromIssue] } - - return { - componentsToState: componentsToState, - componentsToMetadata: componentsToMetadata, - }; + )) } -const triage = async(componentsToIssueNumber, componentsToIssueMetadata, componentsToPlanState) => { - let slugs = new Set([...Object.keys(componentsToIssueNumber), ...Object.keys(componentsToPlanState)]); - - const componentsCandidatesToCreateIssue = []; - const componentsCandidatesToCloseIssue = []; - const componentsToUpdateExistingIssue = []; - const removedComponents = []; - const recoveredComponents = []; - const driftingComponents = []; - const erroredComponents = []; - - for (let slug of slugs) { - if (componentsToIssueNumber.hasOwnProperty(slug)) { - const issueNumber = componentsToIssueNumber[slug].number; - - if (componentsToPlanState.hasOwnProperty(slug)) { - const drifted = componentsToPlanState[slug].drifted; - const error = componentsToPlanState[slug].error; - - if (drifted || error) { - const commitSHA = componentsToIssueMetadata[slug].commitSHA; - const currentSHA = "${{ github.sha }}"; - if (currentSHA === commitSHA) { - core.info(`Component "${slug}" marked as drifted but default branch SHA didn't change so nothing to update. Skipping ...`); - if (error) { - erroredComponents.push(slug) - } else { - driftingComponents.push(slug); - } - } else { - core.info(`Component "${slug}" is still drifting. Issue ${issueNumber} needs to be updated.`); - componentsToUpdateExistingIssue.push(slug); - if (error) { - erroredComponents.push(slug) - } else { - driftingComponents.push(slug); - } - } - } else { - core.info(`Component "${slug}" is not drifting anymore. Issue ${issueNumber} needs to be closed.`); - componentsCandidatesToCloseIssue.push(slug); - recoveredComponents.push(slug); - } - } else { - core.info(`Component "${slug}" has been removed. Issue ${issueNumber} needs to be closed.`); - componentsCandidatesToCloseIssue.push(slug); - removedComponents.push(slug); - } - } else { - const drifted = componentsToPlanState[slug].drifted; - const error = componentsToPlanState[slug].error; - - if (drifted) { - core.info(`Component "${slug}" drifted. New issue has to be created.`); - componentsCandidatesToCreateIssue.push(slug); - driftingComponents.push(slug); - } else if (error) { - core.info(`Component "${slug}" drift error. New issue has to be created.`); - componentsCandidatesToCreateIssue.push(slug); - erroredComponents.push(slug); - } else { - core.info(`Component "${slug}" is not drifting. Skipping ...`); - } - } - } +const mapArtifactToComponents = (path) => { + const files = fs.readdirSync(path); + const metadataFiles = files.filter(file => file.endsWith('metadata.json')); + const result = metadataFiles.map( + (file) => { + const metadata = JSON.parse(fs.readFileSync(file, 'utf8')); - return { - componentsCandidatesToCreateIssue: componentsCandidatesToCreateIssue, - componentsToUpdateExistingIssue: componentsToUpdateExistingIssue, - removedComponents: removedComponents, - recoveredComponents: recoveredComponents, - driftingComponents: driftingComponents, - erroredComponents: erroredComponents, - componentsCandidatesToCloseIssue: componentsCandidatesToCloseIssue, + const stackFromArchive = new StackFromArchive(metadata); + return [stackFromArchive.slug, stackFromArchive] } + ) + return new Map(result) } -const closeIssues = async (octokit, context, componentsToIssueNumber, removedComponents, recoveredComponents) => { - const componentsToCloseIssuesFor = removedComponents.concat(recoveredComponents); - - const repository = context.repo; - - for (let i = 0; i < componentsToCloseIssuesFor.length; i++) { - const slug = componentsToCloseIssuesFor[i]; - const issueNumber = componentsToIssueNumber[slug].number; - - octokit.rest.issues.update({ - ...repository, - issue_number: issueNumber, - state: "closed" - }); - - if (componentsToIssueNumber[slug].error) { - octokit.rest.issues.addLabels({ - ...repository, - issue_number: issueNumber, - labels: ['error-recovered'] - }); - } else { - octokit.rest.issues.addLabels({ - ...repository, - issue_number: issueNumber, - labels: ['drift-recovered'] - }); - } +const getOperationsList = async (stacksFromIssues, stacksFromArtifact, users, labels, maxOpenedIssues, processAll) => { - let comment = `Component \`${slug}\` is not drifting anymore`; - if ( removedComponents.hasOwnProperty(slug) ) { - comment = `Component \`${slug}\` has been removed`; - } else if ( componentsToIssueNumber[slug].error ) { - comment = `Failure \`${slug}\` solved`; - } + const stacks = processAll ? + [...stacksFromIssues.keys(), ...stacksFromArtifact.keys()] : + [...stacksFromArtifact.keys()] + const slugs = [...new Set(stacks)] // get unique set - octokit.rest.issues.createComment({ - ...repository, - issue_number: issueNumber, - body: comment, - }); + const operations = slugs.map((slug) => { + const issue = stacksFromIssues.get(slug) + const state = stacksFromArtifact.get(slug) + if (issue && state) { + if (state.error || state.drifted) { + const commitSHA = issue.metadata.commitSHA; + const currentSHA = "${{ github.sha }}"; - core.info(`Issue ${issueNumber} for component ${slug} has been closed with comment: ${comment}`); - } -} + return currentSHA === commitSHA ? new Skip(issue, state) : new Update(issue, state, labels) + } + return new Close(issue, state) -const convertTeamsToUsers = async (octokit, orgName, teams) => { - let users = []; - - if (teams.length === 0) { - console.log("No users to assign issue with. Skipping ..."); - } else { - try { - let usersFromTeams = []; - - for (let i = 0; i < teams.length; i++) { - const response = await octokit.rest.teams.listMembersInOrg({ - org: orgName, - team_slug: teams[i] - }); - - const usersForCurrentTeam = response.data.map(user => user.login); - usersFromTeams = usersFromTeams.concat(usersForCurrentTeam); - } - - users = users.concat(usersFromTeams); - users = [...new Set(users)]; // get unique set - } catch (error) { - core.error(`Failed to associate user to an issue. Error ${error.message}`); - users = []; - } + } else if (issue) { + return new Remove(issue) + } else if (state && (state.error || state.drifted)) { + return new Create(state, users, labels) } - return users; -} + return new Nothing() + }) -const createIssues = async (octokit, context, maxOpenedIssues, labels, users, componentsToIssues, componentsCandidatesToCreateIssue, componentsCandidatesToCloseIssue, erroredComponents) => { - const repository = context.repo; - const numberOfMaximumPotentialIssuesThatCanBeCreated = Math.max(0, maxOpenedIssues - Object.keys(componentsToIssues).length + componentsCandidatesToCloseIssue.length); - const numOfIssuesToCreate = Math.min(numberOfMaximumPotentialIssuesThatCanBeCreated, componentsCandidatesToCreateIssue.length); - const componentsToNewlyCreatedIssues = {}; - - for (let i = 0; i < numOfIssuesToCreate; i++) { - const slug = componentsCandidatesToCreateIssue[i]; - const issueTitle = erroredComponents.includes(slug) ? `Failure Detected in \`${slug}\`` : `Drift Detected in \`${slug}\``; - const file_name = slug.replace(/\//g, "_") - if (!fs.existsSync(`issue-description-${file_name}.md`)) { - core.error(`Failed to create issue for component ${slug} because file "issue-description-${file_name}.md" does not exist`); - continue; - } - const issueDescription = fs.readFileSync(`issue-description-${file_name}.md`, 'utf8'); - - const label = erroredComponents.includes(slug) ? "error" : "drift" - - const newIssue = await octokit.rest.issues.create({ - ...repository, - title: issueTitle, - body: issueDescription, - labels: [label].concat(labels) - }); + const openedIssuesCounts = operations.filter((operation) => { + return operation instanceof Update + }).length - const issueNumber = newIssue.data.number; + const numberOfMaximumPotentialIssuesThatCanBeCreated = Math.max(0, maxOpenedIssues - openedIssuesCounts); + // If maxOpenedIssues is negative then it will not limit the number of issues to create + let numOfIssuesToCreate = Math.min(numberOfMaximumPotentialIssuesThatCanBeCreated, maxOpenedIssues); - componentsToNewlyCreatedIssues[slug] = issueNumber; - - core.info(`Created new issue with number: ${issueNumber}`); - - core.setOutput('issue-number', issueNumber); - - if (users.length > 0) { - try { - await octokit.rest.issues.addAssignees({ - ...repository, - issue_number: issueNumber, - assignees: users - }); - } catch (error) { - core.error(`Failed to associate user to an issue. Error ${error.message}`); - } - } + return operations.map((operation) => { + if (operation instanceof Create) { + if (numOfIssuesToCreate > 0) { + numOfIssuesToCreate -= 1 + } else if (numOfIssuesToCreate === 0) { + return new Skip(operation.issue, operation.state, maxOpenedIssues); + } } - return componentsToNewlyCreatedIssues; + return operation + }) } -const updateIssues = async (octokit, context, componentsToIssues, componentsToUpdateExistingIssue) => { - const repository = context.repo; +const convertTeamsToUsers = async (octokit, orgName, teams) => { + let users = []; - for (let i = 0; i < componentsToUpdateExistingIssue.length; i++) { - const slug = componentsToUpdateExistingIssue[i]; - const file_name = slug.replace(/\//g, "_") - const issueDescription = fs.readFileSync(`issue-description-${file_name}.md`, 'utf8'); - const issueNumber = componentsToIssues[slug].number; + if (teams.length === 0) { + console.log("No users to assign issue with. Skipping ..."); + } else { + try { + let usersFromTeams = []; - octokit.rest.issues.update({ - ...repository, - issue_number: issueNumber, - body: issueDescription - }); + for (let i = 0; i < teams.length; i++) { + const response = await octokit.rest.teams.listMembersInOrg({ + org: orgName, + team_slug: teams[i] + }); - core.info(`Updated issue: ${issueNumber}`); + const usersForCurrentTeam = response.data.map(user => user.login); + usersFromTeams = usersFromTeams.concat(usersForCurrentTeam); + } + + users = users.concat(usersFromTeams); + users = [...new Set(users)]; // get unique set + } catch (error) { + core.error(`Failed to associate user to an issue. Error ${error.message}`); + users = []; } + } + + return users; } -const postDriftDetectionSummary = async (context, maxOpenedIssues, componentsToIssues, componentsToNewlyCreatedIssues, componentsCandidatesToCreateIssue, removedComponents, recoveredComponents, driftingComponents, erroredComponents) => { - const orgName = context.repo.owner; - const repo = context.repo.repo; - const runId = github.context.runId; +const driftDetectionTable = (results) => { - const table = [ `| Component | State | Comments |`]; - table.push(`|---|---|---|`) + const table = [ + `| Component | State | Comments |`, + `|-----------|-------|----------|` + ]; - for (let slug of Object.keys(componentsToNewlyCreatedIssues)) { - const issueNumber = componentsToNewlyCreatedIssues[slug]; + results.map((result) => { + return result.render() + }).filter((result) => { + return result !== "" + }).forEach((result) => { + table.push(result) + }) - if (driftingComponents.includes(slug)) { - table.push( `| [${slug}](https://github.com/${orgName}/${repo}/actions/runs/${runId}#user-content-result-${slug}) | ![drifted](https://shields.io/badge/DRIFTED-important?style=for-the-badge "Drifted") | New drift detected. Created new issue [#${issueNumber}](https://github.com/${orgName}/${repo}/issues/${issueNumber}) |`); - } else if (erroredComponents.includes(slug)) { - table.push( `| [${slug}](https://github.com/${orgName}/${repo}/actions/runs/${runId}#user-content-result-${slug}) | ![failed](https://shields.io/badge/FAILED-ff0000?style=for-the-badge "Failed") | Failure detected. Created new issue [#${issueNumber}](https://github.com/${orgName}/${repo}/issues/${issueNumber}) |`); - } - } + if (table.length > 2) { + return ['# Drift Detection Summary', table.join("\n")] + } - for (let i = 0; i < componentsCandidatesToCreateIssue.length; i++) { - const slug = componentsCandidatesToCreateIssue[i]; + return ["No drift detected"] +} - if (!componentsToNewlyCreatedIssues.hasOwnProperty(slug)) { - if (driftingComponents.includes(slug)) { - table.push( `| [${slug}](https://github.com/${orgName}/${repo}/actions/runs/${runId}#user-content-result-${slug}) | ![drifted](https://shields.io/badge/DRIFTED-important?style=for-the-badge "Drifted") | New drift detected. Issue was not created because maximum number of created issues ${maxOpenedIssues} reached |`); - } else if (erroredComponents.includes(slug)) { - table.push( `| [${slug}](https://github.com/${orgName}/${repo}/actions/runs/${runId}#user-content-result-${slug}) | ![failed](https://shields.io/badge/FAILED-ff0000?style=for-the-badge "Failed") | Failure detected. Issue was not created because maximum number of created issues ${maxOpenedIssues} reached |`); - } - } - } +const postSummaries = async (table, components) => { + // GitHub limits summary per step to 1MB + // https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#step-isolation-and-limits + const maximumLength = Math.pow(2, 20); + let totalLength = 0; + let currentLength = 0; - for (let i = 0; i < removedComponents.length; i++) { - const slug = removedComponents[i]; - const issueNumber = componentsToIssues[slug].number; + totalLength += table.join("\n").length + let summary = core.summary.addRaw(table.join("\n"), true) - table.push( `| [${slug}](https://github.com/${orgName}/${repo}/actions/runs/${runId}#user-content-result-${slug}) | ![removed](https://shields.io/badge/REMOVED-grey?style=for-the-badge "Removed") | Component has been removed. Closed issue [#${issueNumber}](https://github.com/${orgName}/${repo}/issues/${issueNumber}) |`); - } + const componentsWithSummary = components.map((component) => { + const fullSummary = component.summary() + const shortSummary = component.shortSummary() - for (let i = 0; i < recoveredComponents.length; i++) { - const slug = recoveredComponents[i]; - const issueNumber = componentsToIssues[slug].number; - if (componentsToIssues[slug].error) { - table.push( `| [${slug}](https://github.com/${orgName}/${repo}/actions/runs/${runId}#user-content-result-${slug}) | ![recovered](https://shields.io/badge/RECOVERED-brightgreen?style=for-the-badge "Recovered") | Failure recovered. Closed issue [#${issueNumber}](https://github.com/${orgName}/${repo}/issues/${issueNumber}) |`); - } else { - table.push( `| [${slug}](https://github.com/${orgName}/${repo}/actions/runs/${runId}#user-content-result-${slug}) | ![recovered](https://shields.io/badge/RECOVERED-brightgreen?style=for-the-badge "Recovered") | Drift recovered. Closed issue [#${issueNumber}](https://github.com/${orgName}/${repo}/issues/${issueNumber}) |`); - } - } + totalLength += fullSummary.length - for (let i = 0; i < driftingComponents.length; i++) { - const slug = driftingComponents[i]; - if (componentsCandidatesToCreateIssue.indexOf(slug) === -1) { - const issueNumber = componentsToIssues[slug].number; - table.push( `| [${slug}](https://github.com/${orgName}/${repo}/actions/runs/${runId}#user-content-result-${slug}) | ![drifted](https://shields.io/badge/DRIFTED-important?style=for-the-badge "Drifted") | Drift detected. Issue already exists [#${issueNumber}](https://github.com/${orgName}/${repo}/issues/${issueNumber}) |`); - } + return { + totalLength: totalLength, + fullSummary: fullSummary, + shortSummary: shortSummary } - - for (let i = 0; i < erroredComponents.length; i++) { - const slug = erroredComponents[i]; - if (componentsCandidatesToCreateIssue.indexOf(slug) === -1) { - const issueNumber = componentsToIssues[slug].number; - table.push( `| [${slug}](https://github.com/${orgName}/${repo}/actions/runs/${runId}#user-content-result-${slug}) | ![failed](https://shields.io/badge/FAILED-ff0000?style=for-the-badge "Failed") | Failure detected. Issue already exists [#${issueNumber}](https://github.com/${orgName}/${repo}/issues/${issueNumber}) |`); - } + }).filter((item) => { + return item.fullSummary !== "" + }).reverse().map((item) => { + if (item.totalLength + currentLength <= maximumLength) { + return item.fullSummary } + currentLength += item.shortSummary.length + return item.shortSummary + }).reverse() - if (table.length > 1) { - await core.summary - .addRaw('# Drift Detection Summary', true) - .addRaw(table.join("\n"), true) - .write() - } else { - await core.summary.addRaw("No drift detected").write(); - } -} -const postStepSummaries = async (driftingComponents, erroredComponents) => { - const components = driftingComponents.concat(erroredComponents) - for (let i = 0; i < components.length; i++) { - const slug = components[i]; - const file_name = slug.replace(/\//g, "_") - const file = `step-summary-${file_name}.md`; - const content = fs.readFileSync(file, 'utf-8'); + componentsWithSummary.forEach((item) => { + summary.addRaw(item, true) + }) - await core.summary.addRaw(content).write(); - } + await summary.write(); } /** @@ -408,46 +221,36 @@ const postStepSummaries = async (driftingComponents, erroredComponents) => { * @param {Object} parameters */ const runAction = async (octokit, context, parameters) => { - const { - maxOpenedIssues = 0, - assigneeUsers = [], - assigneeTeams = [], - labels = [] - } = parameters; - - await downloadArtifacts("metadata"); - - const openGitHubIssuesToComponents = await mapOpenGitHubIssuesToComponents(octokit, context); - const componentsToIssueNumber = openGitHubIssuesToComponents.componentsToIssues; - const componentsToIssueMetadata = openGitHubIssuesToComponents.componentsToMetadata; - - const metadataFromPlanArtifacts = await readMetadataFromPlanArtifacts(); - const componentsToPlanState = metadataFromPlanArtifacts.componentsToState; - - const triageResults = await triage(componentsToIssueNumber, componentsToIssueMetadata, componentsToPlanState); - const componentsCandidatesToCreateIssue = triageResults.componentsCandidatesToCreateIssue; - const componentsToUpdateExistingIssue = triageResults.componentsToUpdateExistingIssue; - const removedComponents = triageResults.removedComponents; - const recoveredComponents = triageResults.recoveredComponents; - const driftingComponents = triageResults.driftingComponents; - const erroredComponents = triageResults.erroredComponents; - const componentsCandidatesToCloseIssue = triageResults.componentsCandidatesToCloseIssue; - - await closeIssues(octokit, context, componentsToIssueNumber, removedComponents, recoveredComponents); + const { + maxOpenedIssues = 0, + assigneeUsers = [], + assigneeTeams = [], + labels = [], + processAll = false, + } = parameters; + + const stacksFromArtifact = await downloadArtifacts("metadata").then( + (path) => { + return mapArtifactToComponents(path) + } + ) - const usersFromTeams = await convertTeamsToUsers(octokit, context.repo.owner, assigneeTeams); - let users = assigneeUsers.concat(usersFromTeams); - users = [...new Set(users)]; // get unique set + const stacksFromIssues = await mapOpenGitHubIssuesToComponents(octokit, context, labels); - const componentsToNewlyCreatedIssues = await createIssues(octokit, context, maxOpenedIssues, labels, users, componentsToIssueNumber, componentsCandidatesToCreateIssue, componentsCandidatesToCloseIssue, erroredComponents); + const usersFromTeams = await convertTeamsToUsers(octokit, context.repo.owner, assigneeTeams); + let users = assigneeUsers.concat(usersFromTeams); + users = [...new Set(users)]; // get unique set - await updateIssues(octokit, context, componentsToIssueNumber, componentsToUpdateExistingIssue); + const operations = await getOperationsList(stacksFromIssues, stacksFromArtifact, users, labels, maxOpenedIssues, processAll); - await postDriftDetectionSummary(context, maxOpenedIssues, componentsToIssueNumber, componentsToNewlyCreatedIssues, componentsCandidatesToCreateIssue, removedComponents, recoveredComponents, driftingComponents, erroredComponents); + const results = await Promise.all(operations.map((item) => { + return item.run(octokit, context) + })) - await postStepSummaries(driftingComponents, erroredComponents); -}; + const table = driftDetectionTable(results); + await postSummaries(table, operations); +} module.exports = { - runAction -}; + runAction +} diff --git a/src/index.js b/src/index.js index f78e885cb..e8e9e6c49 100644 --- a/src/index.js +++ b/src/index.js @@ -10,6 +10,7 @@ try { const assigneeUsers = parseCsvInput(core.getInput('assignee-users')); const assigneeTeams = parseCsvInput(core.getInput('assignee-teams')); const labels = parseCsvInput(core.getInput('labels')); + const processAll = core.getBooleanInput('process-all'); // Get octokit const octokit = github.getOctokit(token); @@ -19,7 +20,8 @@ try { maxOpenedIssues, assigneeUsers, assigneeTeams, - labels + labels, + processAll }); } catch (error) { core.setFailed(error.message); diff --git a/src/models/stacks_from_archive.js b/src/models/stacks_from_archive.js new file mode 100644 index 000000000..a27c3743a --- /dev/null +++ b/src/models/stacks_from_archive.js @@ -0,0 +1,12 @@ +class StackFromArchive { + constructor(metadata) { + this.metadata = metadata; + this.drifted = metadata.drifted; + this.error = metadata.error; + this.slug = `${metadata.stack}-${metadata.component}`; + } +} + +module.exports = { + StackFromArchive, +} diff --git a/src/models/stacks_from_issues.js b/src/models/stacks_from_issues.js new file mode 100644 index 000000000..1bd7e7e1d --- /dev/null +++ b/src/models/stacks_from_issues.js @@ -0,0 +1,25 @@ +const getMetadataFromIssueBody = (body) => { + const regex = /```json\s([\s\S]+?)\s```/; + const matched = body.match(regex); + + if (matched && matched[1]) { + return JSON.parse(matched[1]); + } + return null +} + +class StackFromIssue { + constructor(issue) { + const metadata = getMetadataFromIssueBody(issue.body); + this.metadata = metadata; + this.error = issue.title.startsWith('Failure Detected in'); + this.slug = `${metadata.stack}-${metadata.component}`; + this.number = issue.number; + } +} + + +module.exports = { + StackFromIssue, + getMetadataFromIssueBody +} \ No newline at end of file diff --git a/src/operations/close.js b/src/operations/close.js new file mode 100644 index 000000000..2be86007f --- /dev/null +++ b/src/operations/close.js @@ -0,0 +1,55 @@ +const core = require("@actions/core"); +const {Recovered} = require("../results/recovered"); + +class Close { + constructor(issue, state) { + this.issue = issue; + this.state = state; + } + + async run(octokit, context) { + const repository = context.repo; + + const slug = this.issue.slug; + const issueNumber = this.issue.number; + + octokit.rest.issues.update({ + ...repository, + issue_number: issueNumber, + state: "closed" + }); + + const label = this.issue.error ? 'error-recovered' : 'drift-recovered'; + + octokit.rest.issues.addLabels({ + ...repository, + issue_number: issueNumber, + labels: [label] + }); + + let comment = this.issue.error ? `Failure \`${slug}\` solved` : `Component \`${slug}\` is not drifting anymore`; + octokit.rest.issues.createComment({ + ...repository, + issue_number: issueNumber, + body: comment, + }); + + core.info(`Issue ${issueNumber} for component ${slug} has been closed with comment: ${comment}`); + + return new Recovered(context.runId, repository, issueNumber, this.state) + } + + summary() { + return ""; + } + + shortSummary() { + return ""; + } + +} + + +module.exports = { + Close +} \ No newline at end of file diff --git a/src/operations/create.js b/src/operations/create.js new file mode 100644 index 000000000..7a2a75d13 --- /dev/null +++ b/src/operations/create.js @@ -0,0 +1,69 @@ +const core = require("@actions/core"); +const {NewCreated} = require("../results/new-created"); +const {readFileSync} = require("fs"); +const {getFileName} = require("../utils"); + +class Create { + constructor(state, users, labels) { + this.state = state; + this.users = users; + this.labels = labels; + } + + async run(octokit, context) { + const repository = context.repo; + + const slug = this.state.slug; + const issueTitle = this.state.error ? `Failure Detected in \`${slug}\`` : `Drift Detected in \`${slug}\``; + const file_name = getFileName(slug); + const issueDescription = readFileSync(`issue-description-${file_name}.md`, 'utf8'); + + const label = this.state.error ? "error" : "drift" + + const newIssue = await octokit.rest.issues.create({ + ...repository, + title: issueTitle, + body: issueDescription, + labels: [label].concat(this.labels) + }); + + const issueNumber = newIssue.data.number; + + core.info(`Created new issue with number: ${issueNumber}`); + + if (this.users.length > 0) { + try { + await octokit.rest.issues.addAssignees({ + ...repository, + issue_number: issueNumber, + assignees: this.users + }); + } catch (error) { + core.error(`Failed to associate user to an issue. Error ${error.message}`); + } + } + + return new NewCreated(context.runId, repository, issueNumber, this.state); + } + + summary() { + const file_name = getFileName(this.state.slug); + const file = `step-summary-${file_name}.md`; + return readFileSync(file, 'utf-8'); + } + + shortSummary() { + const component = this.state.metadata.component; + const stack = this.state.metadata.stack; + const title = this.state.error ? + `## Plan Failed for \`${component}\` in \`${stack}\`` : + `## Changes Found for \`${component}\` in \`${stack}\``; + const body = `Summary is unavailable due to [GitHub size limitation](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#step-isolation-and-limits) on job summaries. Please check the GitHub Action run logs for more details.` + return [title, body].join("\n"); + } +} + + +module.exports = { + Create +} \ No newline at end of file diff --git a/src/operations/nothing.js b/src/operations/nothing.js new file mode 100644 index 000000000..20bb678c5 --- /dev/null +++ b/src/operations/nothing.js @@ -0,0 +1,23 @@ +const {None} = require("../results/none"); + +class Nothing { + constructor() { + } + + async run() { + return new None(); + } + + summary() { + return ""; + } + + shortSummary() { + return ""; + } +} + + +module.exports = { + Nothing +} \ No newline at end of file diff --git a/src/operations/remove.js b/src/operations/remove.js new file mode 100644 index 000000000..e5acb32d9 --- /dev/null +++ b/src/operations/remove.js @@ -0,0 +1,51 @@ +const core = require("@actions/core"); +const github = require("@actions/github"); +const {Removed} = require("../results/removed"); + +class Remove { + constructor(issue) { + this.issue = issue; + } + + async run(octokit, context) { + const repository = context.repo; + + const slug = this.issue.slug; + const issueNumber = this.issue.number; + + octokit.rest.issues.update({ + ...repository, + issue_number: issueNumber, + state: "closed" + }); + + octokit.rest.issues.addLabels({ + ...repository, + issue_number: issueNumber, + labels: ['removed'] + }); + + const comment = `Component \`${slug}\` has been removed`; + octokit.rest.issues.createComment({ + ...repository, + issue_number: issueNumber, + body: comment, + }); + + core.info(`Issue ${issueNumber} for component ${slug} has been closed with comment: ${comment}`); + + return new Removed(github.context.runId, repository, issueNumber, this.issue); + } + + summary() { + return ""; + } + shortSummary() { + return ""; + } +} + + +module.exports = { + Remove +} \ No newline at end of file diff --git a/src/operations/skip.js b/src/operations/skip.js new file mode 100644 index 000000000..208347440 --- /dev/null +++ b/src/operations/skip.js @@ -0,0 +1,37 @@ +const {NewSkipped} = require("../results/new-skipped"); +const {readFileSync} = require("fs"); +const {getFileName} = require("../utils"); + +class Skip { + constructor(issue, state, maxNumberOpenedIssues) { + this.issue = issue; + this.state = state; + this.maxNumberOpenedIssues = maxNumberOpenedIssues; + } + + async run(octokit, context) { + const repository = context.repo; + return new NewSkipped(context.runId, repository, this.maxNumberOpenedIssues, this.state); + } + + summary() { + const file_name = getFileName(this.state.slug); + const file = `step-summary-${file_name}.md`; + return readFileSync(file, 'utf-8'); + } + + shortSummary() { + const component = this.state.metadata.component; + const stack = this.state.metadata.stack; + const title = this.state.error ? + `## Plan Failed for \`${component}\` in \`${stack}\`` : + `## Changes Found for \`${component}\` in \`${stack}\``; + const body = `Summary is unavailable due to [GitHub size limitation](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#step-isolation-and-limits) on job summaries. Please check the GitHub Action run logs for more details.` + return [title, body].join("\n"); + } +} + + +module.exports = { + Skip +} \ No newline at end of file diff --git a/src/operations/update.js b/src/operations/update.js new file mode 100644 index 000000000..4fab04208 --- /dev/null +++ b/src/operations/update.js @@ -0,0 +1,56 @@ +const core = require("@actions/core"); +const {readFileSync} = require("fs"); +const {Exists} = require("../results/exists"); +const {getFileName} = require("../utils"); + +class Update { + constructor(issue, state, labels) { + this.issue = issue; + this.state = state; + this.labels = labels; + } + + async run(octokit, context) { + const repository = context.repo; + + const slug = this.state.slug; + const file_name = getFileName(slug) + const issueTitle = this.state.error ? `Failure Detected in \`${slug}\`` : `Drift Detected in \`${slug}\``; + const issueDescription = readFileSync(`issue-description-${file_name}.md`, 'utf8'); + const issueNumber = this.issue.number; + const label = this.state.error ? "error" : "drift" + + octokit.rest.issues.update({ + ...repository, + issue_number: issueNumber, + title: issueTitle, + body: issueDescription, + labels: [label].concat(this.labels) + }); + + core.info(`Updated issue: ${issueNumber}`); + + return new Exists(context.runId, repository, issueNumber, this.state) + } + + summary() { + const file_name = getFileName(this.state.slug); + const file = `step-summary-${file_name}.md`; + return readFileSync(file, 'utf-8'); + } + + shortSummary() { + const component = this.state.metadata.component; + const stack = this.state.metadata.stack; + const title = this.state.error ? + `## Plan Failed for \`${component}\` in \`${stack}\`` : + `## Changes Found for \`${component}\` in \`${stack}\``; + const body = `Summary is unavailable due to [GitHub size limitation](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#step-isolation-and-limits) on job summaries. Please check the GitHub Action run logs for more details.` + return [title, body].join("\n"); + } +} + + +module.exports = { + Update +} \ No newline at end of file diff --git a/src/results/exists.js b/src/results/exists.js new file mode 100644 index 000000000..ef2869993 --- /dev/null +++ b/src/results/exists.js @@ -0,0 +1,32 @@ +class Exists { + constructor(runId, repository, newIssueNumber, state) { + this.runId = runId; + this.repository = repository; + this.newIssueNumber = newIssueNumber; + this.state = state; + } + + render() { + const slug = this.state.slug; + const orgName = this.repository.owner; + const repo = this.repository.repo; + const runId = this.runId; + const issueNumber = this.newIssueNumber; + const component = `[${slug}](/${orgName}/${repo}/actions/runs/${runId}#user-content-result-${slug})`; + const state = this.state.error ? + '![failed](https://shields.io/badge/FAILED-ff0000?style=for-the-badge "Failed")' : + '![drifted](https://shields.io/badge/DRIFTED-important?style=for-the-badge "Drifted")'; + + const comments = this.state.error ? + `Failure detected. Issue already exists [#${issueNumber}](/${orgName}/${repo}/issues/${issueNumber})` : + `Drift detected. Issue already exists [#${issueNumber}](/${orgName}/${repo}/issues/${issueNumber})`; + + return [component, state, comments].join(" | "); + } + +} + + +module.exports = { + Exists +} \ No newline at end of file diff --git a/src/results/new-created.js b/src/results/new-created.js new file mode 100644 index 000000000..d14bac8b7 --- /dev/null +++ b/src/results/new-created.js @@ -0,0 +1,32 @@ +class NewCreated { + constructor(runId, repository, newIssueNumber, state) { + this.runId = runId; + this.repository = repository; + this.newIssueNumber = newIssueNumber; + this.state = state; + } + + render() { + const slug = this.state.slug; + const orgName = this.repository.owner; + const repo = this.repository.repo; + const runId = this.runId; + const issueNumber = this.newIssueNumber; + const component = `[${slug}](/${orgName}/${repo}/actions/runs/${runId}#user-content-result-${slug})`; + const state = this.state.error ? + `![failed](https://shields.io/badge/FAILED-ff0000?style=for-the-badge "Failed")` : + '![drifted](https://shields.io/badge/DRIFTED-important?style=for-the-badge "Drifted")'; + + const comments = this.state.error ? + `Failure detected. Created new issue [#${issueNumber}](/${orgName}/${repo}/issues/${issueNumber})` : + `Drift detected. Created new issue [#${issueNumber}](/${orgName}/${repo}/issues/${issueNumber})`; + + return [component, state, comments].join(" | "); + } + +} + + +module.exports = { + NewCreated +} \ No newline at end of file diff --git a/src/results/new-skipped.js b/src/results/new-skipped.js new file mode 100644 index 000000000..92e7aebba --- /dev/null +++ b/src/results/new-skipped.js @@ -0,0 +1,32 @@ +class NewSkipped { + constructor(runId, repository, maxNumberOpenedIssues, state) { + this.runId = runId; + this.repository = repository; + this.maxNumberOpenedIssues = maxNumberOpenedIssues; + this.state = state; + } + + render() { + const slug = this.state.slug; + const orgName = this.repository.owner; + const repo = this.repository.repo; + const runId = this.runId; + const maxOpenedIssues = this.maxNumberOpenedIssues; + const component = `[${slug}](/${orgName}/${repo}/actions/runs/${runId}#user-content-result-${slug})`; + const state = this.state.error ? + `![failed](https://shields.io/badge/FAILED-ff0000?style=for-the-badge "Failed")` : + '![drifted](https://shields.io/badge/DRIFTED-important?style=for-the-badge "Drifted")'; + + const comments = this.state.error ? + `Failure detected. Issue was not created because maximum number of created issues ${maxOpenedIssues} reached` : + `Drift detected. Issue was not created because maximum number of created issues ${maxOpenedIssues} reached`; + + return [component, state, comments].join(" | "); + } + +} + + +module.exports = { + NewSkipped +} \ No newline at end of file diff --git a/src/results/none.js b/src/results/none.js new file mode 100644 index 000000000..1f1e7024d --- /dev/null +++ b/src/results/none.js @@ -0,0 +1,18 @@ +class None { + constructor(runId, repository, newIssue, state) { + this.runId = runId; + this.repository = repository; + this.newIssue = newIssue; + this.state = state; + } + + render() { + return "" + } + +} + + +module.exports = { + None +} \ No newline at end of file diff --git a/src/results/recovered.js b/src/results/recovered.js new file mode 100644 index 000000000..71ec516e1 --- /dev/null +++ b/src/results/recovered.js @@ -0,0 +1,30 @@ +class Recovered { + constructor(runId, repository, newIssueNumber, state) { + this.runId = runId; + this.repository = repository; + this.newIssueNumber = newIssueNumber; + this.state = state; + } + + render() { + const slug = this.state.slug; + const orgName = this.repository.owner; + const repo = this.repository.repo; + const runId = this.runId; + const issueNumber = this.newIssueNumber; + const component = `[${slug}](/${orgName}/${repo}/actions/runs/${runId}#user-content-result-${slug})`; + const state = `![recovered](https://shields.io/badge/RECOVERED-brightgreen?style=for-the-badge "Recovered")`; + + const comments = this.state.error ? + `Failure recovered. Closed issue [#${issueNumber}](/${orgName}/${repo}/issues/${issueNumber})` : + `Drift recovered. Closed issue [#${issueNumber}](/${orgName}/${repo}/issues/${issueNumber})`; + + return [component, state, comments].join(" | "); + } + +} + + +module.exports = { + Recovered +} \ No newline at end of file diff --git a/src/results/removed.js b/src/results/removed.js new file mode 100644 index 000000000..bc35170db --- /dev/null +++ b/src/results/removed.js @@ -0,0 +1,27 @@ +class Removed { + constructor(runId, repository, newIssueNumber, issue) { + this.runId = runId; + this.repository = repository; + this.newIssueNumber = newIssueNumber; + this.issue = issue; + } + + render() { + const slug = this.issue.slug; + const orgName = this.repository.owner; + const repo = this.repository.repo; + const runId = this.runId; + const issueNumber = this.newIssueNumber; + const component = `[${slug}](/${orgName}/${repo}/actions/runs/${runId}#user-content-result-${slug})`; + const state = `![removed](https://shields.io/badge/REMOVED-grey?style=for-the-badge "Removed")`; + const comments = `Component has been removed. Closed issue [#${issueNumber}](/${orgName}/${repo}/issues/${issueNumber})`; + + return [component, state, comments].join(" | "); + } + +} + + +module.exports = { + Removed +} \ No newline at end of file diff --git a/src/utils.js b/src/utils.js index b8d7576a5..9cfc406da 100644 --- a/src/utils.js +++ b/src/utils.js @@ -16,7 +16,13 @@ const parseIntInput = (valueString, defaultValue = 0) => { return value; }; +const getFileName = (slug) => { + return slug.replace(/\//g, "_"); +}; + + module.exports = { parseCsvInput, - parseIntInput + parseIntInput, + getFileName };