diff --git a/HOOKS.md b/HOOKS.md index 11dd6df..a22111c 100644 --- a/HOOKS.md +++ b/HOOKS.md @@ -30,6 +30,7 @@ hooks: | [`conventionalCommits`](#conventionalcommits) | Validates PR titles and commit messages against Conventional Commits specs and posts a status check | | [`wip`](#wip) | Blocks pull requests from being merged if they contain "WIP" in their title | | [`dco`](#dco) | Enforces the Developer Certificate of Origin (`Signed-off-by`) on every commit of a pull request | +| [`label`](#label) | Auto-labels issues and pull requests from title keywords and changed file paths | ## `acknowledge` @@ -119,6 +120,39 @@ Enforces the [Developer Certificate of Origin](https://developercertificate.org/ | `enabled` | `boolean` | `false` | Enables the DCO validation | | `fail` | `boolean` | `true` | Creates a failing check run when a sign-off is missing | +## `label` + +Automatically labels issues and pull requests. Two kinds of rules, both configurable: + +- **Keyword rules** — match words in the title (case-insensitive, whole-word), applied to both issues and pull requests +- **Path rules** — match changed file paths against glob patterns, applied to pull requests only + +When enabled with no config, a small set of sensible defaults is used (`bug`, `enhancement`, `documentation` from title keywords; `documentation`, `tests`, `dependencies` from file paths). Defining your own `keywords` or `paths` replaces the defaults entirely. Labels that don't exist in the repo yet are created by GitHub automatically. + +**Listens to:** `issues.opened`, `pull_request.opened`, `pull_request.reopened`, `pull_request.synchronize` + +### Config + +| Setting | Type | Default | Description | +| --------------------- | ---------- | ------------------ | ------------------------------------------------ | +| `enabled` | `boolean` | `false` | Enables automatic labeling | +| `keywords[].label` | `string` | see defaults above | Label to apply when a keyword matches the title | +| `keywords[].keywords` | `string[]` | see defaults above | Words to look for in the issue/PR title | +| `paths[].label` | `string` | see defaults above | Label to apply when a changed file matches | +| `paths[].paths` | `string[]` | see defaults above | Glob patterns matched against changed file paths | + +```yaml +hooks: + label: + enabled: true + keywords: + - label: bug + keywords: [fix, bug, crash] + paths: + - label: frontend + paths: ["src/ui/**", "**/*.css"] +``` + ## `assign` Automatically assigns reviewers and assignees to pull requests based on file path pattern matching rules. Supports individual user handles (`@user`) and GitHub team slugs (`team-slug`). diff --git a/src/app/hooks/label/issueOpen.ts b/src/app/hooks/label/issueOpen.ts new file mode 100644 index 0000000..90e4822 --- /dev/null +++ b/src/app/hooks/label/issueOpen.ts @@ -0,0 +1,20 @@ +import { defineHook } from "../../../lib/eventHandler.js"; +import { getConfig } from "../../../lib/getConfig.js"; +import { matchKeywordLabels } from "./matchers.js"; + +export default defineHook({ + events: ["issues.opened"], + callback: async (ctx) => { + const config = await getConfig(ctx); + + const { enabled, keywords } = config.hooks.label; + + if (!enabled) return; + + const labels = matchKeywordLabels(ctx.payload.issue.title, keywords); + + if (labels.length === 0) return; + + await ctx.octokit.rest.issues.addLabels(ctx.issue({ labels })); + }, +}); diff --git a/src/app/hooks/label/matchers.ts b/src/app/hooks/label/matchers.ts new file mode 100644 index 0000000..5796d11 --- /dev/null +++ b/src/app/hooks/label/matchers.ts @@ -0,0 +1,39 @@ +import { minimatch } from "minimatch"; + +interface KeywordRule { + label: string; + keywords: string[]; +} + +interface PathRule { + label: string; + paths: string[]; +} + +const escapeRegex = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +export function matchKeywordLabels( + title: string, + rules: KeywordRule[], +): string[] { + return rules + .filter((rule) => + rule.keywords.some((keyword) => + new RegExp(`\\b${escapeRegex(keyword)}\\b`, "i").test(title), + ), + ) + .map((rule) => rule.label); +} + +export function matchPathLabels(files: string[], rules: PathRule[]): string[] { + return rules + .filter((rule) => + files.some((file) => + rule.paths.some((pattern) => + pattern === "*" ? true : minimatch(file, pattern), + ), + ), + ) + .map((rule) => rule.label); +} diff --git a/src/app/hooks/label/prOpen.ts b/src/app/hooks/label/prOpen.ts new file mode 100644 index 0000000..2438c4e --- /dev/null +++ b/src/app/hooks/label/prOpen.ts @@ -0,0 +1,37 @@ +import { defineHook } from "../../../lib/eventHandler.js"; +import { getConfig } from "../../../lib/getConfig.js"; +import { matchKeywordLabels, matchPathLabels } from "./matchers.js"; + +export default defineHook({ + events: [ + "pull_request.opened", + "pull_request.reopened", + "pull_request.synchronize", + ], + callback: async (ctx) => { + const config = await getConfig(ctx); + + const { enabled, keywords, paths } = config.hooks.label; + + if (!enabled) return; + + const files = ( + await ctx.octokit.paginate(ctx.octokit.rest.pulls.listFiles, { + ...ctx.repo(), + pull_number: ctx.payload.pull_request.number, + per_page: 100, + }) + ).map((file) => file.filename); + + const labels = Array.from( + new Set([ + ...matchKeywordLabels(ctx.payload.pull_request.title, keywords), + ...matchPathLabels(files, paths), + ]), + ); + + if (labels.length === 0) return; + + await ctx.octokit.rest.issues.addLabels(ctx.issue({ labels })); + }, +}); diff --git a/src/app/hooks/label/schema.ts b/src/app/hooks/label/schema.ts new file mode 100644 index 0000000..3db0a3e --- /dev/null +++ b/src/app/hooks/label/schema.ts @@ -0,0 +1,45 @@ +import z from "zod"; + +const keywordRuleSchema = z.object({ + label: z.string().min(1), + keywords: z.array(z.string()).default([]), +}); + +const pathRuleSchema = z.object({ + label: z.string().min(1), + paths: z.array(z.string()).default([]), +}); + +export const labelSchema = z.object({ + enabled: z.boolean().default(false), + keywords: z.array(keywordRuleSchema).default([ + { label: "bug", keywords: ["fix", "bug", "crash", "error", "broken"] }, + { + label: "enhancement", + keywords: ["feat", "feature", "add", "improve", "support"], + }, + { + label: "documentation", + keywords: ["docs", "documentation", "readme", "typo"], + }, + ]), + paths: z.array(pathRuleSchema).default([ + { label: "documentation", paths: ["**/*.md", "docs/**"] }, + { + label: "tests", + paths: ["tests/**", "test/**", "**/*.test.*", "**/*.spec.*"], + }, + { + label: "dependencies", + paths: [ + "package.json", + "**/*.lock", + "yarn.lock", + "pnpm-lock.yaml", + "requirements.txt", + "go.mod", + "Cargo.toml", + ], + }, + ]), +}); diff --git a/src/schemas/hooks.ts b/src/schemas/hooks.ts index a36d368..3094f4f 100644 --- a/src/schemas/hooks.ts +++ b/src/schemas/hooks.ts @@ -4,6 +4,7 @@ import { assignSchema } from "../app/hooks/assign/schema.js"; import { conventionalCommitsSchema } from "../app/hooks/conventionalCommits/schema.js"; import { dcoSchema } from "../app/hooks/dco/schema.js"; import { deleteMergedBranchSchema } from "../app/hooks/deleteMergedBranch/schema.js"; +import { labelSchema } from "../app/hooks/label/schema.js"; import { unfurlSchema } from "../app/hooks/unfurl/schema.js"; import { wipSchema } from "../app/hooks/wip/schema.js"; @@ -19,4 +20,5 @@ export const hooksSchema = z.object({ wip: wipSchema.default(wipSchema.parse({})), dco: dcoSchema.default(dcoSchema.parse({})), assign: assignSchema.default(assignSchema.parse({})), + label: labelSchema.default(labelSchema.parse({})), }); diff --git a/tests/app/hooks/label.test.ts b/tests/app/hooks/label.test.ts new file mode 100644 index 0000000..6ea3351 --- /dev/null +++ b/tests/app/hooks/label.test.ts @@ -0,0 +1,191 @@ +import type { Context } from "probot"; +import { describe, expect, it, vi } from "vitest"; +import labelIssueOpen from "../../../src/app/hooks/label/issueOpen.js"; +import { + matchKeywordLabels, + matchPathLabels, +} from "../../../src/app/hooks/label/matchers.js"; +import labelPrOpen from "../../../src/app/hooks/label/prOpen.js"; +import * as configModule from "../../../src/lib/getConfig.js"; +import { defaultConfig, type Config } from "../../../src/schemas/config.js"; + +function withConfig(overrides: Partial): Config { + return { + ...defaultConfig, + hooks: { + ...defaultConfig.hooks, + label: { + ...defaultConfig.hooks.label, + enabled: true, + ...overrides, + }, + }, + }; +} + +function createIssueContext(title: string) { + const addLabelsMock = vi.fn().mockResolvedValue({}); + + const mockCtx = { + payload: { issue: { title, number: 1 } }, + repo: () => ({ owner: "testowner", repo: "testrepo" }), + issue: (extra: Record = {}) => ({ + owner: "testowner", + repo: "testrepo", + issue_number: 1, + ...extra, + }), + octokit: { rest: { issues: { addLabels: addLabelsMock } } }, + } as unknown as Context<"issues.opened">; + + return { mockCtx, addLabelsMock }; +} + +function createPrContext(title: string, files: string[]) { + const addLabelsMock = vi.fn().mockResolvedValue({}); + const paginateMock = vi + .fn() + .mockResolvedValue(files.map((filename) => ({ filename }))); + + const mockCtx = { + payload: { pull_request: { title, number: 1 } }, + repo: () => ({ owner: "testowner", repo: "testrepo" }), + issue: (extra: Record = {}) => ({ + owner: "testowner", + repo: "testrepo", + issue_number: 1, + ...extra, + }), + octokit: { + paginate: paginateMock, + rest: { + issues: { addLabels: addLabelsMock }, + pulls: { listFiles: vi.fn() }, + }, + }, + } as unknown as Context<"pull_request.opened">; + + return { mockCtx, addLabelsMock }; +} + +describe("label matchers", () => { + it("matches title keywords case-insensitively on word boundaries", () => { + const rules = [{ label: "bug", keywords: ["fix", "crash"] }]; + + expect(matchKeywordLabels("Fix the login page", rules)).toEqual(["bug"]); + expect(matchKeywordLabels("CRASH on startup", rules)).toEqual(["bug"]); + expect(matchKeywordLabels("prefix should not match", rules)).toEqual([]); + }); + + it("matches changed files against glob patterns", () => { + const rules = [ + { label: "documentation", paths: ["**/*.md", "docs/**"] }, + { label: "tests", paths: ["tests/**"] }, + ]; + + expect(matchPathLabels(["README.md"], rules)).toEqual(["documentation"]); + expect(matchPathLabels(["docs/guide/intro.txt"], rules)).toEqual([ + "documentation", + ]); + expect(matchPathLabels(["src/index.ts"], rules)).toEqual([]); + expect( + matchPathLabels(["tests/app/foo.test.ts", "README.md"], rules), + ).toEqual(["documentation", "tests"]); + }); +}); + +describe("label hook (issues)", () => { + it("registers issues.opened", () => { + expect(labelIssueOpen.events).toEqual(["issues.opened"]); + }); + + it("does nothing when disabled", async () => { + const { mockCtx, addLabelsMock } = createIssueContext("fix: broken thing"); + vi.spyOn(configModule, "getConfig").mockResolvedValue( + withConfig({ enabled: false }), + ); + + await labelIssueOpen.callback(mockCtx); + + expect(addLabelsMock).not.toHaveBeenCalled(); + }); + + it("labels an issue from title keywords using the defaults", async () => { + const { mockCtx, addLabelsMock } = createIssueContext( + "App crash when opening settings", + ); + vi.spyOn(configModule, "getConfig").mockResolvedValue(withConfig({})); + + await labelIssueOpen.callback(mockCtx); + + expect(addLabelsMock).toHaveBeenCalledWith( + expect.objectContaining({ labels: ["bug"] }), + ); + }); + + it("does not call the API when nothing matches", async () => { + const { mockCtx, addLabelsMock } = createIssueContext("Random question"); + vi.spyOn(configModule, "getConfig").mockResolvedValue(withConfig({})); + + await labelIssueOpen.callback(mockCtx); + + expect(addLabelsMock).not.toHaveBeenCalled(); + }); +}); + +describe("label hook (pull requests)", () => { + it("registers pull_request opened, reopened, and synchronize", () => { + expect(labelPrOpen.events).toEqual([ + "pull_request.opened", + "pull_request.reopened", + "pull_request.synchronize", + ]); + }); + + it("labels a PR from both title keywords and changed paths, deduplicated", async () => { + const { mockCtx, addLabelsMock } = createPrContext("docs: fix readme", [ + "README.md", + "package.json", + ]); + vi.spyOn(configModule, "getConfig").mockResolvedValue(withConfig({})); + + await labelPrOpen.callback(mockCtx); + + expect(addLabelsMock).toHaveBeenCalledWith( + expect.objectContaining({ + labels: ["bug", "documentation", "dependencies"], + }), + ); + }); + + it("uses custom rules when configured, replacing the defaults", async () => { + const { mockCtx, addLabelsMock } = createPrContext("update styles", [ + "src/ui/button.css", + ]); + vi.spyOn(configModule, "getConfig").mockResolvedValue( + withConfig({ + keywords: [], + paths: [{ label: "frontend", paths: ["src/ui/**"] }], + }), + ); + + await labelPrOpen.callback(mockCtx); + + expect(addLabelsMock).toHaveBeenCalledWith( + expect.objectContaining({ labels: ["frontend"] }), + ); + }); + + it("does nothing when disabled", async () => { + const { mockCtx, addLabelsMock } = createPrContext("fix: thing", [ + "README.md", + ]); + vi.spyOn(configModule, "getConfig").mockResolvedValue( + withConfig({ enabled: false }), + ); + + await labelPrOpen.callback(mockCtx); + + expect(addLabelsMock).not.toHaveBeenCalled(); + }); +});