Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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`).
Expand Down
20 changes: 20 additions & 0 deletions src/app/hooks/label/issueOpen.ts
Original file line number Diff line number Diff line change
@@ -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 }));
},
});
39 changes: 39 additions & 0 deletions src/app/hooks/label/matchers.ts
Original file line number Diff line number Diff line change
@@ -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);
}
37 changes: 37 additions & 0 deletions src/app/hooks/label/prOpen.ts
Original file line number Diff line number Diff line change
@@ -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 }));
},
});
45 changes: 45 additions & 0 deletions src/app/hooks/label/schema.ts
Original file line number Diff line number Diff line change
@@ -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",
],
},
]),
});
2 changes: 2 additions & 0 deletions src/schemas/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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({})),
});
191 changes: 191 additions & 0 deletions tests/app/hooks/label.test.ts
Original file line number Diff line number Diff line change
@@ -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["hooks"]["label"]>): 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<string, unknown> = {}) => ({
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<string, unknown> = {}) => ({
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();
});
});