Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
37 changes: 13 additions & 24 deletions torchci/lib/bot/autoLabelBot.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { minimatch } from "minimatch";
import { Context, Probot } from "probot";
import { addLabelErrComment, hasRequiredLabels } from "./checkLabelsUtils";
import { getLabelsFromLabelerConfig } from "./labelerConfigUtils";
import {
addLabels,
CachedIssueTracker,
Expand All @@ -13,6 +13,8 @@ import {
LabelToLabelConfigTracker,
} from "./utils";

export { getLabelsFromLabelerConfig };

// List of regex patterns for assigning labels to both Pull Requests and Issues
const IssueAndPRRegexToLabel: [RegExp, string][] = [
[/rocm/gi, "module: rocm"],
Expand Down Expand Up @@ -140,27 +142,6 @@ const notUserFacingPatterns: RegExp[] = [

const notUserFacingPatternExceptions: RegExp[] = [/tools\/autograd/g];

export async function getLabelsFromLabelerConfig(
context: Context,
labelerConfigTracker: CachedLabelerConfigTracker,
changed_files: string[]
): Promise<string[]> {
const config = await labelerConfigTracker.loadLabelsConfig(context);

const labels = [];

for (const [label, globs] of Object.entries(config)) {
if (
globs.some((glob: string) =>
changed_files.some((file: string) => minimatch(file, glob))
)
) {
labels.push(label);
}
}
return labels;
}

export async function getLabelsFromLabelToLabelConfig(
context: Context,
labelToLabelConfigTracker: LabelToLabelConfigTracker,
Expand Down Expand Up @@ -469,7 +450,12 @@ function myBot(app: Probot): void {
});

app.on(
["pull_request.opened", "pull_request.edited", "pull_request.synchronize"],
[
"pull_request.opened",
"pull_request.edited",
"pull_request.synchronize",
"pull_request.ready_for_review",
],
Comment on lines 452 to +458
async (context) => {
const owner = context.payload.repository.owner.login;
if (!isPyTorchbotSupportedOrg(owner)) {
Expand Down Expand Up @@ -512,10 +498,13 @@ function myBot(app: Probot): void {
}
}

const isDraft = context.payload.pull_request.draft;

var labelsFromLabelerConfig = await getLabelsFromLabelerConfig(
context,
labelerConfigTracker,
filesChanged
filesChanged,
isDraft
);
labelsToAdd.push(...labelsFromLabelerConfig);

Expand Down
84 changes: 84 additions & 0 deletions torchci/lib/bot/labelerConfigUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { minimatch } from "minimatch";
import { Context } from "probot";
import { CachedLabelerConfigTracker } from "./utils";

/** Legacy rules are a list of path globs; extended rules add optional draft behavior. */
export type LabelerRule =
| string[]
| {
globs: string[];
/** false = skip label while PR is draft; true = only when draft; omit = always apply when globs match */
draft?: boolean;
};

export function normalizeLabelerRule(rule: unknown): LabelerRule | null {
if (Array.isArray(rule) && rule.every((x) => typeof x === "string")) {
return rule as string[];
}
if (
rule !== null &&
typeof rule === "object" &&
"globs" in rule &&
Array.isArray((rule as { globs: unknown }).globs) &&
(rule as { globs: unknown[] }).globs.every((x) => typeof x === "string")
) {
const r = rule as { globs: string[]; draft?: unknown };
const out: { globs: string[]; draft?: boolean } = { globs: r.globs };
if (typeof r.draft === "boolean") {
out.draft = r.draft;
}
return out;
Comment on lines +43 to +51
}
return null;
}

export function globsFromRule(rule: LabelerRule): string[] {
return Array.isArray(rule) ? rule : rule.globs;
}

export function draftConstraintAllowsLabel(
rule: LabelerRule,
isDraft: boolean
): boolean {
const draftOpt = Array.isArray(rule) ? undefined : rule.draft;
if (draftOpt === undefined) {
return true;
}
if (draftOpt === false) {
return !isDraft;
}
return isDraft;
}

export async function getLabelsFromLabelerConfig(
context: Context,
labelerConfigTracker: CachedLabelerConfigTracker,
changed_files: string[],
isDraft: boolean
Comment thread
jithunnair-amd marked this conversation as resolved.
Outdated
): Promise<string[]> {
const config = await labelerConfigTracker.loadLabelsConfig(context);
const labels: string[] = [];

for (const [label, rawRule] of Object.entries(config)) {
const rule = normalizeLabelerRule(rawRule);
if (rule === null) {
context.log(
{ label, rawRule },
"getLabelsFromLabelerConfig: unknown rule shape, skipping"
);
continue;
}
if (!draftConstraintAllowsLabel(rule, isDraft)) {
continue;
}
const globs = globsFromRule(rule);
if (
globs.some((glob: string) =>
changed_files.some((file: string) => minimatch(file, glob))
)
) {
labels.push(label);
}
}
return labels;
}
72 changes: 72 additions & 0 deletions torchci/test/autoLabelBot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,78 @@ describe("auto-label-bot: labeler.yml config", () => {
scope2.done();
handleScope(checkLabelsScope);
});

test("labeler draft:false skips ciflow-style label on draft PR", async () => {
const event = requireDeepCopy("./fixtures/pull_request.opened");
event.payload.pull_request.draft = true;
const prFiles = requireDeepCopy("./fixtures/pull_files");
prFiles["items"] = [{ filename: "torch/_dynamo/blah.py" }];
const repoFullName = "zhouzhuojie/gha-ci-playground";
const prNumber = 31;
const scope = mockChangedFiles(prFiles, prNumber, repoFullName);
const config = `
"module: dynamo":
- torch/_dynamo/**

"ciflow/inductor":
globs:
- torch/_dynamo/**
draft: false
`;
utils.mockConfig(
"pytorch-probot.yml",
"labeler_config: labeler.yml",
repoFullName
);
utils.mockConfig("labeler.yml", config, repoFullName);
utils.mockHasApprovedWorkflowRun(repoFullName);
const scope2 = utils.mockAddLabels(
["module: dynamo"],
repoFullName,
prNumber
);
const checkLabelsScope = mockCheckLabelsComment(repoFullName, prNumber);
await probot.receive(event);
scope.done();
scope2.done();
handleScope(checkLabelsScope);
});

test("labeler draft:false applies ciflow-style label when PR is not draft", async () => {
const event = requireDeepCopy("./fixtures/pull_request.opened");
event.payload.pull_request.draft = false;
const prFiles = requireDeepCopy("./fixtures/pull_files");
prFiles["items"] = [{ filename: "torch/_dynamo/blah.py" }];
const repoFullName = "zhouzhuojie/gha-ci-playground";
const prNumber = 31;
const scope = mockChangedFiles(prFiles, prNumber, repoFullName);
const config = `
"module: dynamo":
- torch/_dynamo/**

"ciflow/inductor":
globs:
- torch/_dynamo/**
draft: false
`;
utils.mockConfig(
"pytorch-probot.yml",
"labeler_config: labeler.yml",
repoFullName
);
utils.mockConfig("labeler.yml", config, repoFullName);
utils.mockHasApprovedWorkflowRun(repoFullName);
const scope2 = utils.mockAddLabels(
["module: dynamo", "ciflow/inductor"],
repoFullName,
prNumber
);
const checkLabelsScope = mockCheckLabelsComment(repoFullName, prNumber);
await probot.receive(event);
scope.done();
scope2.done();
handleScope(checkLabelsScope);
});
});

describe("auto-label-bot: label-to-label.yml config", () => {
Expand Down
84 changes: 84 additions & 0 deletions torchci/test/labelerConfigUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
draftConstraintAllowsLabel,
getLabelsFromLabelerConfig,
normalizeLabelerRule,
} from "lib/bot/labelerConfigUtils";

describe("labelerConfigUtils", () => {
describe("normalizeLabelerRule", () => {
test("accepts legacy glob array", () => {
expect(normalizeLabelerRule(["a/**", "b/**"])).toEqual(["a/**", "b/**"]);
});

test("accepts object with globs and draft", () => {
expect(normalizeLabelerRule({ globs: ["x/**"], draft: false })).toEqual({
globs: ["x/**"],
draft: false,
});
});

test("rejects invalid shapes", () => {
expect(normalizeLabelerRule(null)).toBeNull();
expect(normalizeLabelerRule({ globs: "bad" })).toBeNull();
});
});

describe("draftConstraintAllowsLabel", () => {
test("legacy array has no draft constraint", () => {
expect(draftConstraintAllowsLabel(["**"], true)).toBe(true);
expect(draftConstraintAllowsLabel(["**"], false)).toBe(true);
});

test("draft false applies only when not draft", () => {
const rule = { globs: ["**"], draft: false as const };
expect(draftConstraintAllowsLabel(rule, true)).toBe(false);
expect(draftConstraintAllowsLabel(rule, false)).toBe(true);
});

test("draft true applies only on drafts", () => {
const rule = { globs: ["**"], draft: true as const };
expect(draftConstraintAllowsLabel(rule, true)).toBe(true);
expect(draftConstraintAllowsLabel(rule, false)).toBe(false);
});
});

describe("getLabelsFromLabelerConfig", () => {
test("skips label when draft:false and PR is draft", async () => {
const tracker = {
loadLabelsConfig: async () => ({
"ciflow/x": {
globs: ["torch/**"],
draft: false,
},
}),
};
const context = { log: jest.fn() } as any;
const labels = await getLabelsFromLabelerConfig(
context,
tracker as any,
["torch/a.py"],
true
);
expect(labels).toEqual([]);
});

test("adds label when draft:false and PR is not draft", async () => {
const tracker = {
loadLabelsConfig: async () => ({
"ciflow/x": {
globs: ["torch/**"],
draft: false,
},
}),
};
const context = { log: jest.fn() } as any;
const labels = await getLabelsFromLabelerConfig(
context,
tracker as any,
["torch/a.py"],
false
);
expect(labels).toEqual(["ciflow/x"]);
});
});
});
Loading