From 774d440c8016dce381c796357f82907a84df2f0f Mon Sep 17 00:00:00 2001 From: Patrick Brosset Date: Mon, 17 Jul 2023 10:30:08 +0200 Subject: [PATCH] Initial import --- .github/workflows/main.yml | 24 ++++ .gitignore | 1 + README.md | 88 ++++++++++++-- action.yml | 37 ++++++ index.js | 228 +++++++++++++++++++++++++++++++++++++ package.json | 22 ++++ 6 files changed, 392 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 action.yml create mode 100644 index.js create mode 100644 package.json diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..22dd425 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,24 @@ +name: Sync issue to Azure DevOps work item + +on: + issues: + types: + [labeled] + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: MicrosoftEdge/action-issue-to-workitem + env: + ado_token: "${{ secrets.ADO_PERSONAL_ACCESS_TOKEN }}" + github_token: "${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}" + with: + label: 'tracked' + ado_organization: 'microsoft' + ado_project: 'Edge' + ado_tags: 'githubSync;patrickTest' + parent_work_item: 37589346 + ado_area_path: 'Edge\Dev Experience\Developer Tools\F12 Tools' + ado_work_item_type: 'Deliverable' + ado_dont_check_if_exist: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/README.md b/README.md index 5cd7cec..4e3ff4b 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,86 @@ -# Project +# Sync GitHub issues and PRs to Azure DevOps work items -> This repo has been populated by an initial template to help get you started. Please -> make sure to update the content to build a great experience for community-building. +Create a work item in Azure DevOps when a GitHub issue or PR is interacted with. -As the maintainer of this project, please make a few updates: +Use the `issues` or `pull_requests` trigger in your workflow to call this action. -- Improving this README.MD file to provide a great experience -- Updating SUPPORT.MD with content about this project's support experience -- Understanding the security reporting process in SECURITY.MD -- Remove this section from the README +## Inputs + +### `label` + +**Optional**. If specified, only issues or PRs with this label will create ADO items. + +### `ado_organization` + +The name of the ADO organization where work items are to be created. + +### `ado_project` + +The name of the ADO project within the organization. + +### `ado_tags` + +**Optional** tags to be added to the work item (separated by semi-colons). + +### `parent_work_item` + +**Optional** work item number to parent the newly created work item. + +### `ado_area_path` + +An area path to put the work item under. + +### `ado_work_item_type` + +**Optional**. The type of work item to create. Defaults to Bug. + +Common values: Task, Bug, Deliverable, Scenario. + +### `ado_dont_check_if_exist` + +**Optional**. By default, the action tries to find an ADO work item that was already created for this issue or PR. If one is found, no new work item is created. + +Set this to true to avoid checking and just always create a work item instead. + +## Outputs + +### `id` + +The id of the Work Item created or updated + +## Environment variables + +The following environment variables need to be provided to the action: + +* `ado_token`: an [Azure Personal Access Token](https://docs.microsoft.com/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate) with "read & write" permission for Work Item. +* `github_token`: a GitHub Personal Access Token with "repo" permissions. + +## Example usage + +```yaml +name: Sync issue to Azure DevOps work item + +on: + issues: + types: + [labeled] + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: MicrosoftEdge/action-issue-to-workitem + env: + ado_token: "${{ secrets.ADO_PERSONAL_ACCESS_TOKEN }}" + github_token: "${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}" + with: + label: 'tracked' + ado_organization: 'ado_organization_name' + ado_project: 'your_project_name' + ado_tags: 'githubSync' + parent_work_item: 123456789 + ado_area_path: 'optional_area_path' +``` ## Contributing diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..db3280c --- /dev/null +++ b/action.yml @@ -0,0 +1,37 @@ +name: "GitHub Issues to Azure DevOps" +description: "This action creates an ADO work item for a GitHub issue or pull request" +author: "MicrosoftEdge" +branding: + icon: "refresh-cw" + color: "yellow" +inputs: + label: + description: 'Only run the action is this label is present on the issue or pull request' + required: false + ado_organization: + description: 'The name of the ADO organization where the work item should be created' + required: true + ado_project: + description: 'The name of the project within the ADO organization' + required: true + ado_tags: + description: 'A list of tags to add to the newly created work item, separated by semi-colon (;)' + required: false + parent_work_item: + description: 'The number of a work item to use as a parent for the newly created work item' + required: false + ado_area_path: + description: 'The area path under which the work item should be created' + required: true + ado_work_item_type: + description: 'The type of work item to create. Defaults to Bug' + required: false + ado_dont_check_if_exist: + description: 'Do not check if a work item that contains the same issue or PR number already exists to avoid re-creating it. Defaults to false, which means the action checks to avoid re-creating.' + required: false +outputs: + id: + description: "id of work item created" +runs: + using: "node12" + main: "index.js" diff --git a/index.js b/index.js new file mode 100644 index 0000000..27b8a06 --- /dev/null +++ b/index.js @@ -0,0 +1,228 @@ +const core = require(`@actions/core`); +const github = require(`@actions/github`); +const azdev = require(`azure-devops-node-api`); + +async function main() { + const payload = github.context.payload; + const issueOrPr = payload.issue || payload.pull_request; + const isIssue = payload.issue != null; + const isPR = payload.pull_request != null; + + if (core.getInput('label') && !issueOrPr.labels.some(label => label.name === core.getInput('label'))) { + console.log(`Action was configured to only run when issue or PR has label ${core.getInput('label')}, but we couldn't find it.`); + return; + } + + let adoClient = null; + + try { + const orgUrl = "https://dev.azure.com/" + core.getInput('ado_organization'); + const adoAuthHandler = azdev.getPersonalAccessTokenHandler(process.env.ado_token); + const adoConnection = new azdev.WebApi(orgUrl, adoAuthHandler); + adoClient = await adoConnection.getWorkItemTrackingApi(); + } catch (e) { + console.error(e); + core.setFailed('Could not connect to ADO'); + return; + } + + try { + if (!core.getInput('ado_dont_check_if_exist')) { + // go check to see if work item already exists in azure devops or not + // based on the title and tags. + console.log("Check to see if work item already exists"); + const existingID = await find(issueOrPr); + if (!existingID) { + console.log("Could not find existing ADO workitem, creating one now"); + } else { + console.log("Found existing ADO workitem: " + existingID + ". No need to create a new one"); + return; + } + } + + const workItem = await create(payload, adoClient); + + // Add the work item number at the end of the github issue body. + const currentBody = issueOrPr.body || ""; + issueOrPr.body = currentBody + "\n\nAB#" + workItem.id; + const octokit = new github.GitHub(process.env.github_token); + + if (isIssue) { + await octokit.issues.update({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueOrPr.number, + body: issueOrPr.body + }); + } else if (isPR) { + await octokit.pulls.update({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + pull_number: issueOrPr.number, + body: issueOrPr.body + }); + } + + // set output message + if (workItem != null || workItem != undefined) { + console.log(`Work item successfully created or found: ${workItem.id}`); + core.setOutput(`id`, `${workItem.id}`); + } + } catch (error) { + console.log("Error: " + error); + core.setFailed(); + } +} + +function formatTitle(payload) { + const issueOrPr = payload.issue || payload.pull_request; + const isIssue = payload.issue != null; + + return `[GitHub ${isIssue ? "issue" : "PR"} #${issueOrPr.number}] ${issueOrPr.title}`; +} + +async function formatDescription(payload) { + console.log('Creating a description based on the github issue'); + + const issueOrPr = payload.issue || payload.pull_request; + const isIssue = payload.issue != null; + + const octokit = new github.GitHub(process.env.github_token); + const bodyWithMarkdown = await octokit.markdown.render({ + text: issueOrPr.body || "", + mode: "gfm", + context: payload.repository.full_name + }); + + return ` +
+ This work item is a mirror of the GitHub + ${isIssue ? "issue" : "PR"} #${issueOrPr.number}. + It will not auto-update when the GitHub ${isIssue ? "issue" : "PR"} changes, please check the original ${isIssue ? "issue" : "PR"} on GitHub for updates. + +
+
+ ${bodyWithMarkdown.data} + `; +} + +async function create(payload, adoClient) { + const issueOrPr = payload.issue || payload.pull_request; + const botMessage = await formatDescription(payload); + const shortRepoName = payload.repository.full_name.split("/")[1]; + const tags = core.getInput("ado_tags") ? core.getInput("ado_tags") + ";" + shortRepoName : shortRepoName; + const itemType = core.getInput("ado_work_item_type") ? core.getInput("ado_work_item_type") : "Bug"; + + console.log(`Starting to create a ${itemType} work item for GitHub issue or PR #${issueOrPr.number}`); + + const patchDocument = [ + { + op: "add", + path: "/fields/System.Title", + value: formatTitle(payload), + }, + { + op: "add", + path: "/fields/System.Description", + value: botMessage, + }, + { + op: "add", + path: "/fields/Microsoft.VSTS.TCM.ReproSteps", + value: botMessage, + }, + { + op: "add", + path: "/fields/System.Tags", + value: tags, + }, + { + op: "add", + path: "/relations/-", + value: { + rel: "Hyperlink", + url: issueOrPr.html_url, + }, + } + ]; + + if (core.getInput('parent_work_item')) { + let parentUrl = "https://dev.azure.com/" + core.getInput('ado_organization'); + parentUrl += '/_workitems/edit/' + core.getInput('parent_work_item'); + + patchDocument.push({ + op: "add", + path: "/relations/-", + value: { + rel: "System.LinkTypes.Hierarchy-Reverse", + url: parentUrl, + attributes: { + comment: "" + } + } + }); + } + + patchDocument.push({ + op: "add", + path: "/fields/System.AreaPath", + value: core.getInput('ado_area_path'), + }); + + let workItemSaveResult = null; + + try { + console.log('Creating work item'); + workItemSaveResult = await adoClient.createWorkItem( + (customHeaders = []), + (document = patchDocument), + (project = core.getInput('ado_project')), + (type = itemType), + (validateOnly = false), + (bypassRules = false) + ); + + // if result is null, save did not complete correctly + if (workItemSaveResult == null) { + workItemSaveResult = -1; + + console.log("Error: createWorkItem failed"); + console.log(`WIT may not be correct: ${wit}`); + core.setFailed(); + } else { + console.log("Work item successfully created"); + } + } catch (error) { + workItemSaveResult = -1; + + console.log("Error: createWorkItem failed"); + console.log(patchDocument); + console.log(error); + core.setFailed(error); + } + + if (workItemSaveResult != -1) { + console.log(workItemSaveResult); + } + + return workItemSaveResult; +} + +async function find(issueOrPr) { + console.log('Checking if a work item already exists for #' + issueOrPr.number); + + // Isues or PRs that got mirrored have the AB#123456 tag in the body. + // So we can simply look for this and extract the number. + + if (issueOrPr.body != null && issueOrPr.body.includes("AB#")) { + const regex = /AB#(\d+)/g; + const matches = regex.exec(issueOrPr.body); + if (matches != null) { + return matches[1]; + } + } + + return null; +} + +main(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..ab98fa5 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "action-issue-to-workitem", + "version": "1.0.0", + "description": "Create a Work Item on an Azure Board when a GitHub Issue is created or updated", + "main": "index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/MicrosoftEdge/action-issue-to-workitem.git" + }, + "keywords": [], + "author": "", + "license": "GPL-3.0", + "bugs": { + "url": "https://github.com/MicrosoftEdge/action-issue-to-workitem/issues" + }, + "homepage": "https://github.com/MicrosoftEdge/action-issue-to-workitem#readme", + "dependencies": { + "@actions/core": "^1.2.3", + "@actions/github": "^2.1.1", + "azure-devops-node-api": "^10.1.0" + } +}