From b1f9c25f956d695d10a4b63e7a674579a8ed5681 Mon Sep 17 00:00:00 2001 From: Osheun Date: Tue, 18 Aug 2026 23:21:52 +0200 Subject: [PATCH] feat(label): add hook auto-labeling issues and pull requests Labels issues and PRs from title keyword matching (both), and PRs from changed file path globs. Ships with sensible default keyword and path rules that apply when the hook is enabled without config. Closes #36 --- HOOKS.md | 34 ++++++ src/app/hooks/label/issueOpen.ts | 20 ++++ src/app/hooks/label/matchers.ts | 39 +++++++ src/app/hooks/label/prOpen.ts | 37 ++++++ src/app/hooks/label/schema.ts | 45 ++++++++ src/schemas/hooks.ts | 2 + tests/app/hooks/label.test.ts | 191 +++++++++++++++++++++++++++++++ 7 files changed, 368 insertions(+) create mode 100644 src/app/hooks/label/issueOpen.ts create mode 100644 src/app/hooks/label/matchers.ts create mode 100644 src/app/hooks/label/prOpen.ts create mode 100644 src/app/hooks/label/schema.ts create mode 100644 tests/app/hooks/label.test.ts 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(); + }); +});