diff --git a/.github/workflows/__shared-ci.yml b/.github/workflows/__shared-ci.yml index 19516d9..be58d24 100644 --- a/.github/workflows/__shared-ci.yml +++ b/.github/workflows/__shared-ci.yml @@ -58,6 +58,16 @@ jobs: security-events: write uses: $/.github/workflows/__test-action-parse-ci-reports.yml + test-action-need-fix-to-issue: + needs: linter + permissions: + contents: read + id-token: write + packages: read + pull-requests: write + security-events: write + uses: $/.github/workflows/__test-action-need-fix-to-issue.yml + test-action-repository-owner-is-organization: needs: linter permissions: diff --git a/.github/workflows/__test-action-need-fix-to-issue.yml b/.github/workflows/__test-action-need-fix-to-issue.yml new file mode 100644 index 0000000..12f02dd --- /dev/null +++ b/.github/workflows/__test-action-need-fix-to-issue.yml @@ -0,0 +1,22 @@ +name: Internal - Tests for need-fix-to-issue action + +on: + workflow_call: + +permissions: + contents: read + +jobs: + continuous-integration: + uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/continuous-integration.yml@81a4bca8445b7b273455ff26a9a5c3c31c86b2a1 # 0.25.0 + permissions: + contents: read + id-token: write + packages: read + pull-requests: write + security-events: write + with: + working-directory: ./actions/need-fix-to-issue + build: null + lint: null + test: '{ "coverage": null }' diff --git a/.github/workflows/need-fix-to-issue.md b/.github/workflows/need-fix-to-issue.md index 7472c52..7b3de0d 100644 --- a/.github/workflows/need-fix-to-issue.md +++ b/.github/workflows/need-fix-to-issue.md @@ -29,7 +29,9 @@ ## Overview Reusable workflow to convert comments requiring fixes (todo, FIXME) to issues. -Mainly using [Todo to Issue Action](https://github.com/alstr/todo-to-issue-action), with some opinionated defaults. +Uses the [Need fix to Issue action](../../actions/need-fix-to-issue/README.md) to create, categorize, and deduplicate issues. +Generated issues receive the `need-fix` label and the `Bug` issue type when available. +Duplicate issues with the same title and source file are closed. ### Permissions @@ -71,6 +73,14 @@ jobs: +### Issue management + +- Both `TODO` and `FIXME` issues receive the `need-fix` label, in addition to their `help wanted` or `bug` label. Only issues with `need-fix` are managed. Add it to an existing generated issue to include it in categorization and duplicate detection. Pull requests are excluded. +- Untyped generated issues receive the organization's `Bug` issue type. Existing types are preserved. Personal repositories skip typing; organizations without an enabled `Bug` type receive a warning while duplicate detection continues. See [managing issue types](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/managing-issue-types-in-an-organization). +- Duplicate detection keeps the oldest open issue with the exact same title and source file, ignoring commit and line changes in the generated source link. Newer duplicates are closed with a `Duplicate of #...` comment and linked to the original. Different files and issues without a recognized source link are not matched. + +Repositories that need inactivity management can use the separate [Stale workflow](stale.md). + **Tips:** It is recommended to add this trigger for this workflow: diff --git a/.github/workflows/need-fix-to-issue.yml b/.github/workflows/need-fix-to-issue.yml index 70067cf..018c6a9 100644 --- a/.github/workflows/need-fix-to-issue.yml +++ b/.github/workflows/need-fix-to-issue.yml @@ -1,5 +1,7 @@ # Reusable workflow to convert comments requiring fixes (todo, FIXME) to issues. -# Mainly using [Todo to Issue Action](https://github.com/alstr/todo-to-issue-action), with some opinionated defaults. +# Uses the [Need fix to Issue action](../../actions/need-fix-to-issue/README.md) to create, categorize, and deduplicate issues. +# Generated issues receive the need-fix label and the Bug issue type when available. +# Duplicate issues with the same title and source file are closed. name: "Need fix to Issue" on: @@ -35,7 +37,7 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: {} @@ -51,14 +53,8 @@ jobs: with: persist-credentials: false - - name: "TODO to Issue" - uses: "alstr/todo-to-issue-action@37bb7b56e58569ef273b60678048030a7f0c261a" # v5.1.15 + - name: Create and manage need-fix issues + uses: $/actions/need-fix-to-issue with: - IDENTIFIERS: | - [ - {"name": "TODO:", "labels": ["help wanted"]}, - {"name": "FIXME:", "labels": ["bug"]} - ] - env: - MANUAL_COMMIT_REF: ${{ inputs.manual-commit-ref }} - MANUAL_BASE_REF: ${{ inputs.manual-base-ref }} + manual-commit-ref: ${{ inputs.manual-commit-ref }} + manual-base-ref: ${{ inputs.manual-base-ref }} diff --git a/README.md b/README.md index 841487c..c97da8c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Opinionated GitHub Actions and reusable workflows for foundational continuous-in - [Create or update comment](actions/create-or-update-comment/README.md) - adds or updates comments on issues and pull requests idempotently. - [Get GitHub Actions bot user](actions/get-github-actions-bot-user/README.md) - retrieves the profile information for the GitHub Actions bot. - [Get issue number](actions/get-issue-number/README.md) - extracts the relevant issue number from the current workflow context. +- [Need fix to Issue](actions/need-fix-to-issue/README.md) - creates and categorizes TODO/FIXME issues and closes duplicates. ### Matrix & workflow data helpers @@ -43,7 +44,7 @@ Opinionated GitHub Actions and reusable workflows for foundational continuous-in ### Community & issue hygiene - [Greetings](.github/workflows/greetings.md) - welcomes first-time issue reporters and pull request authors. -- [Need fix to Issue](.github/workflows/need-fix-to-issue.md) - labels issues that require follow-up fixes. +- [Need fix to Issue](.github/workflows/need-fix-to-issue.md) - creates and categorizes TODO/FIXME issues and closes duplicates. - [Stale](.github/workflows/stale.md) - automatically marks and closes stale issues and pull requests. ### Quality gates diff --git a/actions/need-fix-to-issue/.gitignore b/actions/need-fix-to-issue/.gitignore new file mode 100644 index 0000000..404abb2 --- /dev/null +++ b/actions/need-fix-to-issue/.gitignore @@ -0,0 +1 @@ +coverage/ diff --git a/actions/need-fix-to-issue/README.md b/actions/need-fix-to-issue/README.md new file mode 100644 index 0000000..c4a598a --- /dev/null +++ b/actions/need-fix-to-issue/README.md @@ -0,0 +1,38 @@ +# Need fix to Issue + +Creates issues from TODO/FIXME comments with [Todo to Issue Action](https://github.com/alstr/todo-to-issue-action), then categorizes and deduplicates open issues carrying the `need-fix` label. Used by the [Need fix to Issue workflow](../../.github/workflows/need-fix-to-issue.md). + +## Behavior + +- Scans the commit diff for `TODO:` and `FIXME:` comments. Both receive the `need-fix` label, in addition to `help wanted` for `TODO:` and `bug` for `FIXME:`. +- Assigns the organization's `Bug` issue type to untyped issues. Existing types are preserved. Personal repositories skip typing; an unavailable `Bug` type produces a warning without preventing duplicate cleanup. +- Keeps the oldest open issue with an exact matching title and source file. Source links must be standalone URLs into the current repository, with a full commit SHA and a line anchor as generated by `alstr/todo-to-issue-action`. Commit and line changes do not affect matching. +- Comments `Duplicate of #` on newer duplicates and closes them with a link to the original. Reruns reuse an existing matching comment. +- Excludes pull requests and leaves issues without recognized source links open. Only issues labeled `need-fix` are considered; applying that label opts an issue into management. +- Propagates API failures, including permission errors, so failed runs can be retried. + +## Usage + +Use the [reusable workflow](../../.github/workflows/need-fix-to-issue.md) to include checkout and runner configuration, or call this composite action after checking out the repository. The job requires `contents: read` and `issues: write` permissions. Both steps use the job's `GITHUB_TOKEN`. The action has no outputs. + +The optional inputs select the commit diff when running manually: + +| Input | Description | Required | +| ------------------- | ------------------------------------------------------------------------------------------------- | -------- | +| `manual-commit-ref` | The SHA of the commit to get the diff for. | No | +| `manual-base-ref` | An earlier SHA to compare against. Defaults to the commit immediately before `manual-commit-ref`. | No | + +## Development + +Run the tests with Node.js as specified in [package.json](package.json): + +```sh +npm test --prefix actions/need-fix-to-issue +``` + +Tests mock the GitHub APIs and do not modify real issues. +The [CI workflow](../../.github/workflows/__test-action-need-fix-to-issue.yml) uses shared Node.js CI and runs `test:ci` to generate an LCOV report at `coverage/lcov.info` inside the action directory. + +## License + +[MIT](../../LICENSE) diff --git a/actions/need-fix-to-issue/action.yml b/actions/need-fix-to-issue/action.yml new file mode 100644 index 0000000..97e2efb --- /dev/null +++ b/actions/need-fix-to-issue/action.yml @@ -0,0 +1,41 @@ +name: Need fix to Issue +description: Create issues from TODO/FIXME comments, assign the Bug issue type, and close duplicates. +author: hoverkraft +branding: + icon: check-circle + color: blue + +inputs: + manual-commit-ref: + description: "The SHA of the commit to get the diff for." + required: false + manual-base-ref: + description: "By default, the commit entered above is compared to the one directly before it; to go back further, enter an earlier SHA here." + required: false + +runs: + using: composite + steps: + - name: TODO to Issue + uses: alstr/todo-to-issue-action@37bb7b56e58569ef273b60678048030a7f0c261a # v5.1.15 + with: + IDENTIFIERS: | + [ + {"name": "TODO:", "labels": ["help wanted", "need-fix"]}, + {"name": "FIXME:", "labels": ["bug", "need-fix"]} + ] + env: + MANUAL_COMMIT_REF: ${{ inputs.manual-commit-ref }} + MANUAL_BASE_REF: ${{ inputs.manual-base-ref }} + + - name: Categorize issues and close duplicates + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ACTION_PATH: ${{ github.action_path }} + with: + script: | + const { pathToFileURL } = require('node:url'); + const { join } = require('node:path'); + const moduleUrl = pathToFileURL(join(process.env.ACTION_PATH, 'src', 'IssueManager.js')); + const { IssueManager } = await import(moduleUrl.href); + await new IssueManager({ github, context, core }).run(); diff --git a/actions/need-fix-to-issue/package-lock.json b/actions/need-fix-to-issue/package-lock.json new file mode 100644 index 0000000..541ae93 --- /dev/null +++ b/actions/need-fix-to-issue/package-lock.json @@ -0,0 +1,16 @@ +{ + "name": "need-fix-to-issue", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "need-fix-to-issue", + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=24.0.0" + } + } + } +} diff --git a/actions/need-fix-to-issue/package.json b/actions/need-fix-to-issue/package.json new file mode 100644 index 0000000..07a883b --- /dev/null +++ b/actions/need-fix-to-issue/package.json @@ -0,0 +1,17 @@ +{ + "name": "need-fix-to-issue", + "version": "1.0.0", + "private": true, + "description": "Create and categorize TODO/FIXME issues and close duplicates", + "type": "module", + "scripts": { + "test": "node --test", + "pretest:ci": "node -e \"require('node:fs').mkdirSync('coverage', { recursive: true })\"", + "test:ci": "node --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test" + }, + "author": "hoverkraft", + "license": "MIT", + "engines": { + "node": ">=24.0.0" + } +} diff --git a/actions/need-fix-to-issue/src/IssueManager.js b/actions/need-fix-to-issue/src/IssueManager.js new file mode 100644 index 0000000..0c5955c --- /dev/null +++ b/actions/need-fix-to-issue/src/IssueManager.js @@ -0,0 +1,95 @@ +export class IssueManager { + constructor({ github, context, core }) { + this.github = github; + this.context = context; + this.core = core; + } + + async run() { + const issues = await this.github.paginate( + this.github.rest.issues.listForRepo, + { + ...this.context.repo, + state: "open", + labels: "need-fix", + sort: "created", + direction: "asc", + per_page: 100, + }, + ); + if (issues.length === 0) return; + const { data: owner } = await this.github.rest.users.getByUsername({ + username: this.context.repo.owner, + }); + this.canAssignType = owner.type === "Organization"; + const originals = new Map(); + for (const issue of issues) { + if (issue.pull_request) continue; + await this.assignBugType(issue); + const key = this.duplicateKey(issue); + if (!key) continue; + const original = originals.get(key); + if (original) { + await this.closeDuplicate(issue, original); + } else { + originals.set(key, issue); + } + } + } + + duplicateKey(issue) { + const { owner, repo } = this.context.repo; + const prefix = `${this.context.serverUrl}/${owner}/${repo}/blob/`; + for (const line of (issue.body ?? "").split("\n")) { + if (!line.startsWith(prefix)) continue; + const source = line + .slice(prefix.length) + .trim() + .match(/^[a-f\d]{40}\/(.+)#L\d+(?:-L\d+)?$/i); + if (source) return JSON.stringify([issue.title, source[1]]); + } + return null; + } + + async assignBugType(issue) { + if (!this.canAssignType || issue.type) return; + try { + await this.github.rest.issues.update({ + ...this.context.repo, + issue_number: issue.number, + type: "Bug", + }); + } catch (error) { + const invalidType = + error.status === 422 && + error.response?.data?.errors?.some(({ field }) => field === "type"); + if (!invalidType) throw error; + this.canAssignType = false; + this.core.warning( + 'The "Bug" issue type is unavailable. Enable it in the organization to categorize generated issues.', + ); + } + } + + async closeDuplicate(issue, original) { + const parameters = { ...this.context.repo, issue_number: issue.number }; + const body = `Duplicate of #${original.number}`; + const comments = await this.github.paginate( + this.github.rest.issues.listComments, + { ...parameters, per_page: 100 }, + ); + if (!comments.some((comment) => comment.body === body)) { + await this.github.rest.issues.createComment({ ...parameters, body }); + } + await this.github.rest.issues.update({ + ...parameters, + headers: { "X-GitHub-Api-Version": "2026-03-10" }, + state: "closed", + state_reason: "duplicate", + duplicate_issue_id: original.id, + }); + this.core.info( + `Closed #${issue.number} as a duplicate of #${original.number}.`, + ); + } +} diff --git a/actions/need-fix-to-issue/src/IssueManager.test.js b/actions/need-fix-to-issue/src/IssueManager.test.js new file mode 100644 index 0000000..2a25208 --- /dev/null +++ b/actions/need-fix-to-issue/src/IssueManager.test.js @@ -0,0 +1,201 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { IssueManager } from "./IssueManager.js"; + +const repo = { owner: "example", repo: "project" }; +const sourceUrl = `https://github.com/example/project/blob/${"a".repeat(40)}/src/app.js#L10`; + +function issue(number, overrides = {}) { + return { + id: number + 1000, + number, + title: "Handle missing input", + body: `${sourceUrl}\n\n\`\`\`js\n// fix the input\n\`\`\``, + type: null, + ...overrides, + }; +} + +function harness( + issues, + { ownerType = "Organization", comments = [], update } = {}, +) { + const calls = { lists: [], updates: [], comments: [], warnings: [] }; + const api = { + listForRepo() {}, + listComments() {}, + async update(parameters) { + calls.updates.push(parameters); + await update?.(parameters); + }, + async createComment(parameters) { + calls.comments.push(parameters); + }, + }; + const github = { + rest: { + issues: api, + users: { + async getByUsername(parameters) { + assert.deepEqual(parameters, { username: repo.owner }); + return { data: { type: ownerType } }; + }, + }, + }, + async paginate(endpoint, parameters) { + calls.lists.push(parameters); + if (endpoint === api.listForRepo) return issues; + assert.equal(endpoint, api.listComments); + return comments; + }, + }; + const context = { + repo, + serverUrl: "https://github.com", + }; + const core = { + warning: (message) => calls.warnings.push(message), + info() {}, + }; + return { manager: new IssueManager({ github, context, core }), calls }; +} + +test("paginates only open need-fix issues from oldest to newest", async () => { + const { manager, calls } = harness([]); + await manager.run(); + assert.deepEqual(calls.lists, [ + { + ...repo, + state: "open", + labels: "need-fix", + sort: "created", + direction: "asc", + per_page: 100, + }, + ]); + assert.deepEqual(calls.updates, []); +}); + +test("assigns Bug to untyped issues and preserves existing types and PRs", async () => { + const { manager, calls } = harness([ + issue(1), + issue(2, { type: { name: "Task" }, title: "Another task" }), + issue(3, { pull_request: {} }), + ]); + await manager.run(); + assert.deepEqual(calls.updates, [{ ...repo, issue_number: 1, type: "Bug" }]); +}); + +test("closes duplicates against the oldest open issue across commits and line changes", async () => { + const { manager, calls } = harness([ + issue(1), + issue(2, { + body: sourceUrl + .replace("a".repeat(40), "b".repeat(40)) + .replace("#L10", "#L50-L52"), + }), + issue(3), + ]); + await manager.run(); + assert.deepEqual( + calls.comments, + [2, 3].map((number) => ({ + ...repo, + issue_number: number, + body: "Duplicate of #1", + })), + ); + assert.deepEqual( + calls.updates.filter(({ state }) => state), + [2, 3].map((number) => ({ + ...repo, + issue_number: number, + headers: { "X-GitHub-Api-Version": "2026-03-10" }, + state: "closed", + state_reason: "duplicate", + duplicate_issue_id: 1001, + })), + ); +}); + +test("does not close issues with different files, titles, or unrecognized source links", async () => { + const { manager, calls } = harness([ + issue(1), + issue(2, { body: sourceUrl.replace("app.js", "other.js") }), + issue(3, { title: "Handle empty input" }), + issue(4, { body: null }), + issue(5, { body: sourceUrl.replace("/example/", "/another/") }), + issue(6, { body: sourceUrl.replace("a".repeat(40), "main") }), + ]); + await manager.run(); + assert.equal(calls.updates.filter(({ state }) => state).length, 0); + assert.deepEqual(calls.comments, []); +}); + +test("reruns leave typed originals unchanged and reuse an existing duplicate comment", async () => { + const { manager, calls } = harness( + [issue(1, { type: { name: "Bug" } }), issue(2, { type: { name: "Bug" } })], + { comments: [{ body: "Duplicate of #1" }] }, + ); + await manager.run(); + assert.deepEqual(calls.comments, []); + assert.equal(calls.updates.length, 1); + assert.equal(calls.updates[0].state, "closed"); + assert.equal(calls.updates[0].issue_number, 2); +}); + +test("personal repositories still close duplicates without assigning organization types", async () => { + const { manager, calls } = harness([issue(1), issue(2)], { + ownerType: "User", + }); + await manager.run(); + assert.equal(calls.updates.length, 1); + assert.equal(calls.updates[0].state, "closed"); +}); + +test("an unavailable Bug type warns once and does not prevent duplicate cleanup", async () => { + const { manager, calls } = harness([issue(1), issue(2), issue(3)], { + update: async ({ type }) => { + if (type) + throw Object.assign(new Error("Validation failed"), { + status: 422, + response: { data: { errors: [{ field: "type", code: "invalid" }] } }, + }); + }, + }); + await manager.run(); + assert.equal(calls.warnings.length, 1); + assert.equal(calls.updates.filter(({ type }) => type).length, 1); + assert.equal(calls.updates.filter(({ state }) => state).length, 2); +}); + +test("permission, rate limit, server and unrelated validation errors fail the run", async () => { + for (const status of [403, 429, 500, 422]) { + const failure = Object.assign(new Error("API failure"), { status }); + const { manager } = harness([issue(1)], { + update: async () => { + throw failure; + }, + }); + await assert.rejects(manager.run(), (error) => error === failure); + } +}); + +test("a failed close can be retried without posting the duplicate comment twice", async () => { + const comments = []; + let failClose = true; + const { manager, calls } = harness([issue(1), issue(2)], { + comments, + update: async ({ state }) => { + if (state && failClose) { + comments.push({ body: calls.comments[0].body }); + throw new Error("Temporary failure"); + } + }, + }); + await assert.rejects(manager.run(), /Temporary failure/); + failClose = false; + await manager.run(); + assert.equal(calls.comments.length, 1); + assert.equal(calls.updates.at(-1).state, "closed"); +});