diff --git a/.github/workflows/pr-link-linear.yaml b/.github/workflows/pr-link-linear.yaml new file mode 100644 index 00000000000..6284ac91c1e --- /dev/null +++ b/.github/workflows/pr-link-linear.yaml @@ -0,0 +1,15 @@ +on: + pull_request: + types: [edited] + +jobs: + link-to-linear: + runs: + on: ubuntu-latest + using: "node22" + run: > + | npm install tsx -g + | tsx ./scripts/pr-link-linear.mts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LINEAR_TOKEN: ${{ secrets.LINEAR_TOKEN }} diff --git a/package.json b/package.json index 7e01a77c5da..cf1a4bf7946 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,8 @@ "@internal/fuel-core": "workspace:*", "@internal/tsup": "workspace:*", "@istanbuljs/nyc-config-typescript": "^1.0.2", + "@linear/sdk": "^30.0.0", + "@octokit/webhooks-definitions": "^3.67.3", "@playwright/test": "^1.47.0", "@types/node": "^22.5.4", "@types/node-fetch": "^2.6.11", @@ -117,8 +119,8 @@ "open": "^10.1.0", "prettier": "^3.3.3", "rimraf": "^5.0.8", - "textlint": "^14.2.0", "syncpack": "12.3.3", + "textlint": "^14.2.0", "textlint-rule-no-dead-link": "^5.2.0", "ts-generator": "^0.1.1", "tsup": "^6.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd9bd6d3ac3..360732d93ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,12 @@ importers: '@istanbuljs/nyc-config-typescript': specifier: ^1.0.2 version: 1.0.2(nyc@17.0.0) + '@linear/sdk': + specifier: ^30.0.0 + version: 30.0.0 + '@octokit/webhooks-definitions': + specifier: ^3.67.3 + version: 3.67.3 '@playwright/test': specifier: ^1.47.0 version: 1.47.0 @@ -3897,6 +3903,10 @@ packages: '@leichtgewicht/ip-codec@2.0.4': resolution: {integrity: sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==} + '@linear/sdk@30.0.0': + resolution: {integrity: sha512-H7FaZxt0qn1AojQd0UBAA+VyUjGnEH3jNhu5oh+O26hfbWiN32dVujLwrapUAeVd7Oyt5hg+5k8+6QVrjBnWOQ==} + engines: {node: '>=12.x', yarn: 1.x} + '@lit-labs/ssr-dom-shim@1.2.1': resolution: {integrity: sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==} @@ -4264,6 +4274,10 @@ packages: '@octokit/types@12.6.0': resolution: {integrity: sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==} + '@octokit/webhooks-definitions@3.67.3': + resolution: {integrity: sha512-do4Z1r2OVhuI0ihJhQ8Hg+yPWnBYEBNuFNCrvtPKoYT1w81jD7pBXgGe86lYuuNirkDHb0Nxt+zt4O5GiFJfgA==} + deprecated: Use @octokit/webhooks-types, @octokit/webhooks-schemas, or @octokit/webhooks-examples instead. See https://github.com/octokit/webhooks/issues/447 + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -9668,6 +9682,10 @@ packages: resolution: {integrity: sha512-l0xWZpoPKpppFzMfvVyFmp9vLN7w/ZZJPefUicMCepfJeQ8sMcztloGYY9DfjVPo6tIUDzU5Hw3MUbIjj9AVVA==} engines: {node: '>= 6.x'} + graphql@15.9.0: + resolution: {integrity: sha512-GCOQdvm7XxV1S4U4CGrsdlEN37245eC8P9zaYCMr6K1BG0IPGy5lUwmJsEOGyl1GD6HXjOtl2keCP9asRBwNvA==} + engines: {node: '>= 10.x'} + graphql@16.9.0: resolution: {integrity: sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -19137,6 +19155,10 @@ snapshots: tslib: 2.7.0 value-or-promise: 1.0.12 + '@graphql-typed-document-node/core@3.2.0(graphql@15.9.0)': + dependencies: + graphql: 15.9.0 + '@graphql-typed-document-node/core@3.2.0(graphql@16.9.0)': dependencies: graphql: 16.9.0 @@ -19534,6 +19556,14 @@ snapshots: '@leichtgewicht/ip-codec@2.0.4': {} + '@linear/sdk@30.0.0': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@15.9.0) + graphql: 15.9.0 + isomorphic-unfetch: 3.1.0 + transitivePeerDependencies: + - encoding + '@lit-labs/ssr-dom-shim@1.2.1': {} '@lit/reactive-element@1.6.3': @@ -19976,6 +20006,8 @@ snapshots: dependencies: '@octokit/openapi-types': 20.0.0 + '@octokit/webhooks-definitions@3.67.3': {} + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': @@ -27865,6 +27897,8 @@ snapshots: dependencies: iterall: 1.3.0 + graphql@15.9.0: {} + graphql@16.9.0: {} gzip-size@6.0.0: diff --git a/scripts/pr-link-linear.mts b/scripts/pr-link-linear.mts new file mode 100644 index 00000000000..4744b8a6f70 --- /dev/null +++ b/scripts/pr-link-linear.mts @@ -0,0 +1,118 @@ +import * as core from "@actions/core"; +import * as github from "@actions/github"; +import { LinearClient } from "@linear/sdk"; +import type { PullRequestEditedEvent } from "@octokit/webhooks-definitions/schema"; + +/* + This script should run for `pull_request` updates and will edit their + descriptions, adding markdown comments like the one below: + + + +*/ + +// 1. Ensuring workflow only runs for `pull_request/edit` +if (github.context.eventName === "edit") { + core.setFailed(`Context 'eventName' must be 'edit'.`); +} + +// 2. Setup Octokit and Linear SDK +const { GITHUB_TOKEN, LINEAR_TOKEN } = process.env; + +const octokit = github.getOctokit(GITHUB_TOKEN as string); +const linear = new LinearClient({ apiKey: LINEAR_TOKEN }); + +// 3. Deconstructing repo and payload from workflow context +const REPO = github.context.repo; +const PAYLOAD = github.context.payload as PullRequestEditedEvent; +const { pull_request: PR } = PAYLOAD as PullRequestEditedEvent; + +// 4. Helper for removing linear comment from text +function removeLinearComment(contents) { + const tag = "LINEAR"; + const lineaerCommentsReg = new RegExp( + `^$`, + "gm", + ); + return contents.replace(lineaerCommentsReg, ""); +} + +// 5. Helper for assembling linear comment +async function assembleLinearComment(contents) { + const magicComments: string[] = []; + + const ghLinearKeywords = { + close: "Closes", + closes: "Closes", + closed: "Closes", + fix: "Closes", + fixes: "Closes", + fixed: "Closes", + resolve: "Closes", + resolves: "Closes", + resolved: "Closes", + "relates to": "Part of", + }; + + const issueKeywordNumberReg = /^[\s]*-[\s]*([^\s]+).+#([0-9]+)/gm; + + // eslint-disable-next-line no-constant-condition + while (true) { + // Match all keyword mentions individually + const regexMatch = issueKeywordNumberReg.exec(contents); + + // Abort when done + if (!regexMatch) { + break; + } + + // Deconstruct what we want + const [keyword, ghIssueNumber] = regexMatch.slice(1); + + // Find related GH issue + const { data: ghIssue } = await octokit.rest.issues.get({ + ...REPO, + issue_number: Number(ghIssueNumber), + }); + + // Find related Linear issue + const res = await linear.searchIssues(ghIssue.title, { first: 1 }); + + // Flag error if not found + if (!res.totalCount) { + magicComments.push(` - Not found: #${ghIssueNumber} — ${ghIssue.title}`); + } + + // Otherwise add magic word mention + const linearIssueId = res.nodes[0].identifier; + const linearMagicWord = ghLinearKeywords[keyword.toLowerCase()]; + + magicComments.push(` - ${linearMagicWord} ${linearIssueId}`); + } + + // Assemble and return entier comment + const comment = [""].join("\n"); + + return comment; +} + +// 6. Action +await (async () => { + const rawContents = PR.body; + + const cleanContents = removeLinearComment(rawContents); + const linearComment = await assembleLinearComment(cleanContents); + + const body = [linearComment, cleanContents].join("\n"); + + await octokit.rest.pulls.update({ + owner: PAYLOAD.repository.owner.login, + repo: PAYLOAD.repository.name, + pull_number: PR.number, + body, + }); +})();