diff --git a/.github/workflows/linear-check.yml b/.github/workflows/linear-check.yml new file mode 100644 index 000000000..bf1dad039 --- /dev/null +++ b/.github/workflows/linear-check.yml @@ -0,0 +1,29 @@ +name: linear ticket check +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + check: + runs-on: ubuntu-latest + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: tools/linear-checker/package-lock.json + + - name: Check comment issues + working-directory: tools/linear-checker + run: npm install && npm run check diff --git a/tools/linear-checker/package-lock.json b/tools/linear-checker/package-lock.json new file mode 100644 index 000000000..50bc285a7 --- /dev/null +++ b/tools/linear-checker/package-lock.json @@ -0,0 +1,106 @@ +{ + "name": "linear-checker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linear-checker", + "version": "1.0.0", + "dependencies": { + "@linear/sdk": "^32.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@linear/sdk": { + "version": "32.0.0", + "resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-32.0.0.tgz", + "integrity": "sha512-ZENFrq3JIztYSEmHRmt+HYgfEAqCd/vIVJNDNp3vpedz+0M/FZZSnxe1qTOQToIASvxWbaJtc2M6QT/cIcXRyA==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.0", + "graphql": "^15.4.0", + "isomorphic-unfetch": "^3.1.0" + }, + "engines": { + "node": ">=12.x", + "yarn": "1.x" + } + }, + "node_modules/graphql": { + "version": "15.9.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.9.0.tgz", + "integrity": "sha512-GCOQdvm7XxV1S4U4CGrsdlEN37245eC8P9zaYCMr6K1BG0IPGy5lUwmJsEOGyl1GD6HXjOtl2keCP9asRBwNvA==", + "license": "MIT", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/isomorphic-unfetch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz", + "integrity": "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "unfetch": "^4.2.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/unfetch": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", + "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/tools/linear-checker/package.json b/tools/linear-checker/package.json new file mode 100644 index 000000000..7fbbc9670 --- /dev/null +++ b/tools/linear-checker/package.json @@ -0,0 +1,14 @@ +{ + "name": "linear-checker", + "version": "1.0.0", + "description": "Checks if issues in the code comments are assigned to a Linear ticket", + "main": "src/index.js", + "author": "Metatype Team", + "type": "module", + "scripts": { + "check": "node ./src/index.js" + }, + "dependencies": { + "@linear/sdk": "^32.0.0" + } +} diff --git a/tools/linear-checker/src/index.js b/tools/linear-checker/src/index.js new file mode 100644 index 000000000..80f0f1550 --- /dev/null +++ b/tools/linear-checker/src/index.js @@ -0,0 +1,95 @@ +// Copyright Metatype OÜ, licensed under the Mozilla Public License Version 2.0. +// SPDX-License-Identifier: MPL-2.0 + +import { execSync } from "node:child_process"; +import { LinearClient } from "@linear/sdk"; + +const apiKey = process.env.LINEAR_API_KEY; + +let metaTeam, linearClient; + +async function getIssue(number) { + if (!linearClient) { + linearClient = new LinearClient({ apiKey }); + metaTeam = await linearClient.team("MET"); + } + + const issues = await metaTeam.issues({ + filter: { + number: { eq: number }, + }, + }); + + return issues.nodes[0]; +} + +function getAdditions(diff) { + const [header, ...changes] = diff.split("\n"); + const [_, match] = header.match(/\+(\d+)/); + const startLine = parseInt(match); + return changes + .filter((c) => c.startsWith("+")) + .map((addition, i) => ({ + addition: addition.slice(1), + line: startLine + i, + })); +} + +const command = "git diff --color=never --unified=0 main...HEAD"; +const output = execSync(command, { encoding: "utf-8" }); + +const fileAdditions = output + .split(/(?=^diff --git)/m) + .map((file) => file.split(/(?=^@@)/m)) + .map((blocks) => { + const [header, ...diffs] = blocks; + const [_, fileName] = header.match(/^\+\+\+ b\/(.+)/m); + const additions = diffs.flatMap((d) => getAdditions(d)); + return { fileName, additions }; + }); + +const issues = fileAdditions.flatMap(({ fileName, additions }) => + additions + .map(({ addition, line }) => { + const [_, type, desc] = addition.match(/(TODO|FIXME):? (.+)/) ?? []; + const [__, match] = desc ? (desc.match(/MET-(\d+)/) ?? []) : []; + const ticket = match && parseInt(match); + return { file: fileName, type, desc, line, ticket, source: addition }; + }) + .filter((issue) => issue.desc), +); + +let foundInvalidIssue = false; + +for (const issue of issues) { + const { file, type, desc, line, ticket, source } = issue; + + if (!issue.ticket) { + console.error( + `Error: A Linear ticket was not found for the issue "${type}: ${desc}" in the file "${file}" at line ${line}.`, + "Consider creating a Linear ticket for this issue and referencing it in the comment.", + ); + console.error(`${line} | ${source}`); + foundInvalidIssue = true; + } else { + const issue = await getIssue(ticket); + + if (!issue) { + console.error( + `Error: The ticket MET-${ticket} referenced in the file "${file}" at line ${line} does not exist`, + ); + console.error(`${line} | ${source}`); + foundInvalidIssue = true; + } else { + console.info( + `Info: Found a Linear ticket "MET-${ticket}" in the file "${file}" at line ${line} (${type}: ${desc})`, + ); + } + } +} + +if (!issues.length) { + console.log("No issues or tickets found"); +} else if (foundInvalidIssue) { + process.exit(1); +}