Skip to content

Commit 08ab86b

Browse files
authored
Dedup the PR changed-files fetch shared by autoLabelBot and nitpickBot (#8584)
Stack from [ghstack](https://github.com/ezyang/ghstack/tree/0.14.0) (oldest at bottom): * __->__ #8584 * #8582 * #8580 * #8579 * #8578 * #8576 **Impact:** PyTorchBot webhook handlers (autoLabelBot, nitpickBot) — GitHub API usage only, no behavior change **Risk:** low ## What Adds a short-TTL, head-sha-keyed cache (`getFilesChangedByPrCached`) around the paginated `GET /pulls/{n}/files` fetch, and points both autoLabelBot and nitpickBot at it. ## Why Both bots run on the same `pull_request` delivery and each independently paginated the PR's changed files, doubling that read on every PR push. The two handlers are staggered (nitpick loads its config first), so caching the resolved result — not just an in-flight promise — lets the second handler reuse the first's fetch. This halves the file-list reads per push, part of the broader effort to cut PyTorchBot's GitHub rate-limit consumption. ## Notes - Cache is keyed on head sha so a re-push misses naturally; the 60s TTL only bounds memory. - Failed fetches are dropped from the cache immediately so a later handler re-fetches, matching the previous uncached on-error behavior. - Tests clear the cache in `beforeEach` to keep them independent. Signed-off-by: Jean Schmidt <contato@jschmidt.me>
1 parent 5514186 commit 08ab86b

6 files changed

Lines changed: 160 additions & 7 deletions

File tree

torchci/lib/bot/autoLabelBot.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
addLabels,
1313
CachedIssueTracker,
1414
CachedLabelerConfigTracker,
15-
getFilesChangedByPr,
15+
getFilesChangedByPrCached,
1616
hasApprovedPullRuns,
1717
hasWritePermissions,
1818
isPyTorchbotSupportedOrg,
@@ -544,11 +544,13 @@ function myBot(app: Probot): void {
544544
);
545545
const repo = context.payload.repository.name;
546546
const title = context.payload.pull_request.title;
547-
const filesChanged = await getFilesChangedByPr(
547+
const filesChanged = await getFilesChangedByPrCached(
548548
context.octokit,
549+
context.id,
549550
owner,
550551
repo,
551-
context.payload.pull_request.number
552+
context.payload.pull_request.number,
553+
context.payload.pull_request.head.sha
552554
);
553555
context.log({ labels, title, filesChanged });
554556

torchci/lib/bot/nitpickBot.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as yaml from "js-yaml";
22
import { minimatch } from "minimatch";
33
import { Context, Probot } from "probot";
4-
import { getFilesChangedByPr, isPyTorchPyTorch } from "./utils";
4+
import { getFilesChangedByPrCached, isPyTorchPyTorch } from "./utils";
55

66
// Implements logic similar to https://github.com/ethanis/nitpicker.
77
// Reads `.github/nitpicks.yml` from the repo's default branch and posts
@@ -162,11 +162,13 @@ export default function nitpickBot(app: Probot): void {
162162
return;
163163
}
164164

165-
const filesChanged = await getFilesChangedByPr(
165+
const filesChanged = await getFilesChangedByPrCached(
166166
context.octokit,
167+
context.id,
167168
owner,
168169
repo,
169-
prNum
170+
prNum,
171+
context.payload.pull_request.head.sha
170172
);
171173
const matched = getMatchingRules(filesChanged, rules);
172174
const newBody = formNitpickComment(matched);

torchci/lib/bot/utils.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,3 +406,63 @@ export async function getFilesChangedByPr(
406406
);
407407
return filesChangedRes.map((f: any) => f.filename);
408408
}
409+
410+
export const FILES_CHANGED_CACHE_TTL_MS = 60 * 1000;
411+
412+
interface FilesChangedCacheEntry {
413+
promise: Promise<string[]>;
414+
expiresAt: number;
415+
}
416+
417+
// autoLabelBot and nitpickBot both fetch a PR's changed files on the same
418+
// pull_request delivery, staggered (nitpick loads its config first). Caching
419+
// the resolved fetch — not just the in-flight promise — lets the second
420+
// handler reuse the first's paginated GET /pulls/{n}/files. The delivery id
421+
// scopes an entry to a single webhook delivery: GitHub computes the file list
422+
// against the PR's base, and retargeting the base changes that list without
423+
// changing head.sha, so an entry is only ever safe to reuse within the
424+
// delivery that produced it. The TTL only bounds memory.
425+
const filesChangedCache = new Map<string, FilesChangedCacheEntry>();
426+
427+
export function getFilesChangedByPrCached(
428+
octokit: Octokit,
429+
deliveryId: string,
430+
owner: string,
431+
repo: string,
432+
prNumber: number,
433+
headSha: string
434+
): Promise<string[]> {
435+
const key = `${deliveryId}/${owner}/${repo}/${prNumber}/${headSha}`;
436+
const now = Date.now();
437+
438+
const cached = filesChangedCache.get(key);
439+
if (cached && cached.expiresAt > now) {
440+
return cached.promise;
441+
}
442+
443+
for (const [k, entry] of filesChangedCache) {
444+
if (entry.expiresAt <= now) {
445+
filesChangedCache.delete(k);
446+
}
447+
}
448+
449+
const promise = getFilesChangedByPr(octokit, owner, repo, prNumber);
450+
filesChangedCache.set(key, {
451+
promise,
452+
expiresAt: now + FILES_CHANGED_CACHE_TTL_MS,
453+
});
454+
// A failed fetch must not be served for the rest of the TTL; drop it so a
455+
// later handler re-fetches (matching the uncached on-error behavior).
456+
promise.catch(() => {
457+
if (filesChangedCache.get(key)?.promise === promise) {
458+
filesChangedCache.delete(key);
459+
}
460+
});
461+
462+
return promise;
463+
}
464+
465+
/** Clear the in-memory files-changed cache (useful for testing). */
466+
export function clearFilesChangedCache(): void {
467+
filesChangedCache.clear();
468+
}

torchci/test/autoLabelBot.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import { mockAddLabels } from "./utils";
1212

1313
nock.disableNetConnect();
1414

15+
beforeEach(() => {
16+
botUtils.clearFilesChangedCache();
17+
});
18+
1519
describe("auto-label-bot", () => {
1620
let probot: Probot;
1721
function emptyMockConfig(repoFullName: string) {

torchci/test/nitpickBot.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import nitpickBot, {
55
NITPICK_COMMENT_START,
66
parseNitpickConfig,
77
} from "lib/bot/nitpickBot";
8+
import { clearFilesChangedCache } from "lib/bot/utils";
89
import nock from "nock";
910
import { Probot } from "probot";
1011
import { handleScope, requireDeepCopy } from "./common";
@@ -175,6 +176,7 @@ describe("nitpickBot probot integration", () => {
175176
`;
176177

177178
beforeEach(() => {
179+
clearFilesChangedCache();
178180
probot = utils.testProbot();
179181
probot.load(nitpickBot);
180182
utils.mockAccessToken();

torchci/test/utils.test.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { hasApprovedPullRuns } from "lib/bot/utils";
1+
import {
2+
clearFilesChangedCache,
3+
getFilesChangedByPrCached,
4+
hasApprovedPullRuns,
5+
} from "lib/bot/utils";
26
import nock from "nock";
37
import { Probot } from "probot";
48
import * as utils from "./utils";
@@ -89,3 +93,82 @@ describe("utils: hasApprovedPullRuns", () => {
8993
await checkhasApprovedPullRunsReturns(false);
9094
});
9195
});
96+
97+
describe("utils: getFilesChangedByPrCached", () => {
98+
beforeEach(() => clearFilesChangedCache());
99+
afterEach(() => clearFilesChangedCache());
100+
101+
test("dedupes by head sha; re-fetches on sha change or after clear", async () => {
102+
const octokit = {
103+
paginate: jest
104+
.fn()
105+
.mockResolvedValue([{ filename: "a.py" }, { filename: "b.py" }]),
106+
} as any;
107+
const call = (sha: string) =>
108+
getFilesChangedByPrCached(
109+
octokit,
110+
"delivery1",
111+
"pytorch",
112+
"pytorch",
113+
1,
114+
sha
115+
);
116+
117+
expect(await call("sha1")).toEqual(["a.py", "b.py"]);
118+
expect(await call("sha1")).toEqual(["a.py", "b.py"]);
119+
expect(octokit.paginate).toHaveBeenCalledTimes(1);
120+
121+
await call("sha2");
122+
expect(octokit.paginate).toHaveBeenCalledTimes(2);
123+
124+
clearFilesChangedCache();
125+
await call("sha1");
126+
expect(octokit.paginate).toHaveBeenCalledTimes(3);
127+
});
128+
129+
test("dedupes within one delivery; re-fetches across deliveries", async () => {
130+
const octokit = {
131+
paginate: jest.fn().mockResolvedValue([{ filename: "a.py" }]),
132+
} as any;
133+
const call = (deliveryId: string) =>
134+
getFilesChangedByPrCached(
135+
octokit,
136+
deliveryId,
137+
"pytorch",
138+
"pytorch",
139+
1,
140+
"sha1"
141+
);
142+
143+
await call("delivery1");
144+
await call("delivery1");
145+
expect(octokit.paginate).toHaveBeenCalledTimes(1);
146+
147+
await call("delivery2");
148+
expect(octokit.paginate).toHaveBeenCalledTimes(2);
149+
});
150+
151+
test("a later delivery sees its own file list after a base retarget", async () => {
152+
const octokit = {
153+
paginate: jest
154+
.fn()
155+
.mockResolvedValueOnce([{ filename: "torch/csrc/foo.cpp" }])
156+
.mockResolvedValueOnce([{ filename: "docs/readme.md" }]),
157+
} as any;
158+
// Retargeting a PR's base fires pull_request.edited without moving
159+
// head.sha, so every key component except the delivery id is identical.
160+
const call = (deliveryId: string) =>
161+
getFilesChangedByPrCached(
162+
octokit,
163+
deliveryId,
164+
"pytorch",
165+
"pytorch",
166+
1,
167+
"sha1"
168+
);
169+
170+
expect(await call("delivery1")).toEqual(["torch/csrc/foo.cpp"]);
171+
expect(await call("delivery2")).toEqual(["docs/readme.md"]);
172+
expect(octokit.paginate).toHaveBeenCalledTimes(2);
173+
});
174+
});

0 commit comments

Comments
 (0)