megareviewer 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -26356,7 +26356,7 @@ import { fileURLToPath } from "node:url";
26356
26356
  // package.json
26357
26357
  var package_default = {
26358
26358
  name: "megareviewer",
26359
- version: "0.1.0",
26359
+ version: "0.2.0",
26360
26360
  description: "Guided code review for GitHub and Bitbucket Cloud pull requests: intent first, changes sorted by risk, decisions to approve.",
26361
26361
  keywords: [
26362
26362
  "code-review",
@@ -26510,6 +26510,7 @@ init_credentials();
26510
26510
  import { randomBytes } from "node:crypto";
26511
26511
  import { mkdirSync } from "node:fs";
26512
26512
  import { join as join4 } from "node:path";
26513
+ import { setTimeout as sleep2 } from "node:timers/promises";
26513
26514
 
26514
26515
  // node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/classic/external.js
26515
26516
  var external_exports = {};
@@ -47189,11 +47190,14 @@ var AnalysisError = external_exports.object({
47189
47190
  message: external_exports.string()
47190
47191
  }).readonly();
47191
47192
  var AnalysisStatus = external_exports.enum(["running", "complete", "failed"]);
47193
+ var FilesFetched = external_exports.object({ done: external_exports.number().int().nonnegative(), total: external_exports.number().int().nonnegative() }).readonly();
47192
47194
  var Analysis = external_exports.object({
47193
47195
  id: AnalysisId,
47194
47196
  key: AnalysisKey,
47195
47197
  status: AnalysisStatus,
47196
47198
  stages: external_exports.record(StageName, StageStatus).readonly(),
47199
+ /** null until Collect starts fetching. Defaults for analyses stored before it existed. */
47200
+ filesFetched: FilesFetched.nullable().default(null),
47197
47201
  pullRequest: PullRequest.nullable(),
47198
47202
  files: external_exports.array(FileChange).readonly(),
47199
47203
  tickets: external_exports.array(Ticket).readonly(),
@@ -47567,6 +47571,7 @@ var networkError = (error62) => {
47567
47571
  const cause = error62.cause instanceof Error ? error62.cause.message : error62.message;
47568
47572
  return hostError("network", `Can't reach Bitbucket: ${cause}`);
47569
47573
  };
47574
+ var timedOut = (timeoutMs) => hostError("network", `Bitbucket didn't answer within ${timeoutMs / 1e3} seconds`);
47570
47575
  var invalidResponse = (what, error62) => hostError(
47571
47576
  "invalid-response",
47572
47577
  `Unexpected response from Bitbucket for ${what}: ${typeof error62 === "string" ? error62 : external_exports.prettifyError(error62)}`
@@ -47578,10 +47583,12 @@ var API_ORIGIN = new URL(API).origin;
47578
47583
  var REDIRECTS = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
47579
47584
  var MAX_REDIRECTS = 5;
47580
47585
  var MAX_PAGES = 10;
47586
+ var REQUEST_TIMEOUT_MS = 6e4;
47581
47587
  var createBitbucketClient = (options) => {
47582
47588
  const { logger } = options;
47589
+ const timeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
47583
47590
  const authorization = `Basic ${Buffer.from(`${options.username}:${options.password}`).toString("base64")}`;
47584
- const send = async (url2, accept, request2 = {}) => {
47591
+ const send = async (url2, accept, request2, signal) => {
47585
47592
  let target = url2;
47586
47593
  for (let hop = 0; ; hop += 1) {
47587
47594
  const toApi = new URL(target).origin === API_ORIGIN;
@@ -47591,6 +47598,7 @@ var createBitbucketClient = (options) => {
47591
47598
  response = await options.fetch(target, {
47592
47599
  method: request2.method ?? "GET",
47593
47600
  redirect: "manual",
47601
+ signal,
47594
47602
  headers: {
47595
47603
  accept,
47596
47604
  ...toApi ? { authorization } : {},
@@ -47609,17 +47617,28 @@ var createBitbucketClient = (options) => {
47609
47617
  target = new URL(location, target).href;
47610
47618
  }
47611
47619
  };
47612
- const receive = async (url2, accept, request2 = {}) => {
47613
- const response = await send(url2, accept, request2);
47614
- if (!response.ok || response.value.ok) return response;
47615
- const { status, headers } = response.value;
47616
- const body2 = await response.value.text();
47617
- return err2(fromErrorResponse({ status, headers, body: body2 }, request2.call ?? "read"));
47620
+ const call = async (url2, accept, request2, read) => {
47621
+ const controller = new AbortController();
47622
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
47623
+ try {
47624
+ const response = await send(url2, accept, request2, controller.signal);
47625
+ if (!response.ok) return response;
47626
+ if (!response.value.ok) {
47627
+ const { status, headers } = response.value;
47628
+ const body2 = await response.value.text();
47629
+ return err2(fromErrorResponse({ status, headers, body: body2 }, request2.call ?? "read"));
47630
+ }
47631
+ return await read(response.value);
47632
+ } catch (error62) {
47633
+ if (controller.signal.aborted) return err2(timedOut(timeoutMs));
47634
+ if (error62 instanceof TypeError) return err2(networkError(error62));
47635
+ throw error62;
47636
+ } finally {
47637
+ clearTimeout(timer);
47638
+ }
47618
47639
  };
47619
- const json3 = async (what, schema, url2, request2 = {}) => {
47620
- const response = await receive(url2, "application/json", request2);
47621
- if (!response.ok) return response;
47622
- const text = await response.value.text();
47640
+ const json3 = async (what, schema, url2, request2 = {}) => call(url2, "application/json", request2, async (response) => {
47641
+ const text = await response.text();
47623
47642
  let data;
47624
47643
  try {
47625
47644
  data = text === "" ? null : JSON.parse(text);
@@ -47628,7 +47647,7 @@ var createBitbucketClient = (options) => {
47628
47647
  }
47629
47648
  const parsed = schema.safeParse(data);
47630
47649
  return parsed.success ? ok(parsed.data) : err2(invalidResponse(what, parsed.error));
47631
- };
47650
+ });
47632
47651
  const pages = async (what, item, url2) => {
47633
47652
  const page = BitbucketPage(item);
47634
47653
  const items = [];
@@ -47648,11 +47667,15 @@ var createBitbucketClient = (options) => {
47648
47667
  return {
47649
47668
  json: json3,
47650
47669
  pages,
47651
- async text(url2, accept) {
47652
- const response = await receive(url2, accept);
47653
- if (!response.ok) return response;
47654
- return ok(new TextDecoder().decode(await response.value.arrayBuffer()));
47655
- }
47670
+ text: (url2, accept) => (
47671
+ // Bitbucket sends files and diffs in the repository's encoding, with no charset.
47672
+ call(
47673
+ url2,
47674
+ accept,
47675
+ {},
47676
+ async (response) => ok(new TextDecoder().decode(await response.arrayBuffer()))
47677
+ )
47678
+ )
47656
47679
  };
47657
47680
  };
47658
47681
 
@@ -47713,6 +47736,7 @@ var VERDICT_ENDPOINTS = {
47713
47736
  // src/adapters/bitbucket/bitbucket-host.ts
47714
47737
  var SCANNED_REPOS = 50;
47715
47738
  var SCAN_CONCURRENCY = 5;
47739
+ var FILE_CONCURRENCY = 8;
47716
47740
  var PAGE_SIZE = 50;
47717
47741
  var FATAL = /* @__PURE__ */ new Set([
47718
47742
  "unauthorized",
@@ -47739,7 +47763,8 @@ var createBitbucketHost = (options) => {
47739
47763
  username: options.username,
47740
47764
  password: options.password,
47741
47765
  logger,
47742
- fetch: options.fetch ?? globalThis.fetch
47766
+ fetch: options.fetch ?? globalThis.fetch,
47767
+ requestTimeoutMs: options.requestTimeoutMs
47743
47768
  });
47744
47769
  let cachedUser = null;
47745
47770
  const currentUser = async () => {
@@ -47863,11 +47888,21 @@ var createBitbucketHost = (options) => {
47863
47888
  })
47864
47889
  );
47865
47890
  },
47866
- async getFile(repo, sha, path4) {
47867
- const encodedPath = path4.split("/").map(segment).join("/");
47868
- const content = await client.text(`${repoUrl(repo)}/src/${sha}/${encodedPath}`, "*/*");
47869
- if (!content.ok && content.error.kind === "not-found") return ok(null);
47870
- return content;
47891
+ async getFiles(repo, files) {
47892
+ const contents = await mapLimit(files, FILE_CONCURRENCY, async ({ sha, path: path4 }) => {
47893
+ const encodedPath = path4.split("/").map(segment).join("/");
47894
+ const content = await client.text(`${repoUrl(repo)}/src/${sha}/${encodedPath}`, "*/*");
47895
+ if (!content.ok && (content.error.kind === "not-found" || content.error.kind === "too-large")) {
47896
+ return ok(null);
47897
+ }
47898
+ return content;
47899
+ });
47900
+ const found = [];
47901
+ for (const content of contents) {
47902
+ if (!content.ok) return content;
47903
+ found.push(content.value);
47904
+ }
47905
+ return ok(found);
47871
47906
  },
47872
47907
  async listPullRequests() {
47873
47908
  if (workspace === null) return ok([]);
@@ -52542,6 +52577,15 @@ var GitHubText = external_exports.union([
52542
52577
  external_exports.string(),
52543
52578
  external_exports.instanceof(ArrayBuffer).transform((buffer) => new TextDecoder().decode(buffer))
52544
52579
  ]);
52580
+ var GitHubObject = external_exports.object({
52581
+ __typename: external_exports.string(),
52582
+ text: external_exports.string().nullable().optional(),
52583
+ isBinary: external_exports.boolean().nullable().optional(),
52584
+ isTruncated: external_exports.boolean().optional()
52585
+ });
52586
+ var GitHubBlobs = external_exports.object({
52587
+ repository: external_exports.record(external_exports.string(), GitHubObject.nullable())
52588
+ });
52545
52589
  var GitHubSearchResult = external_exports.object({
52546
52590
  total_count: external_exports.number().int().nonnegative(),
52547
52591
  incomplete_results: external_exports.boolean(),
@@ -52585,6 +52629,10 @@ var OctokitError = external_exports.object({
52585
52629
  data: external_exports.unknown()
52586
52630
  }).optional()
52587
52631
  });
52632
+ var GraphqlResponseError2 = external_exports.object({
52633
+ name: external_exports.literal("GraphqlResponseError"),
52634
+ errors: external_exports.array(external_exports.object({ type: external_exports.string().optional(), message: external_exports.string() })).min(1)
52635
+ });
52588
52636
  var hostError2 = (kind, message, retryAfterSeconds3 = null) => ({ kind, message, retryAfterSeconds: retryAfterSeconds3 });
52589
52637
  var header = (response, name2) => {
52590
52638
  const value = response.headers[name2];
@@ -52636,12 +52684,19 @@ var fromErrorResponse2 = (response, now) => {
52636
52684
  return hostError2("unexpected", `GitHub answered ${status}: ${message}`);
52637
52685
  };
52638
52686
  var fromOctokitError = (error62, now) => {
52687
+ const graphql3 = GraphqlResponseError2.safeParse(error62);
52688
+ if (graphql3.success) {
52689
+ const { errors } = graphql3.data;
52690
+ const message2 = `GitHub answered: ${errors.map((detail) => detail.message).join("; ")}`;
52691
+ return hostError2(errors[0]?.type === "NOT_FOUND" ? "not-found" : "unexpected", message2);
52692
+ }
52639
52693
  const parsed = OctokitError.safeParse(error62);
52640
52694
  if (!parsed.success) throw error62;
52641
52695
  const { response, message } = parsed.data;
52642
52696
  if (response === void 0) return hostError2("network", `Can't reach GitHub: ${message}`);
52643
52697
  return fromErrorResponse2(response, now);
52644
52698
  };
52699
+ var timedOut2 = (timeoutMs) => hostError2("network", `GitHub didn't answer within ${timeoutMs / 1e3} seconds`);
52645
52700
  var invalidResponse2 = (what, error62) => hostError2(
52646
52701
  "invalid-response",
52647
52702
  `Unexpected response from GitHub for ${what}: ${external_exports.prettifyError(error62)}`
@@ -52738,8 +52793,23 @@ var DIFF = "application/vnd.github.diff";
52738
52793
  var RAW = "application/vnd.github.raw";
52739
52794
  var MAX_COMMITS = 250;
52740
52795
  var SEARCH_PAGE_SIZE = 50;
52796
+ var FILES_PER_QUERY = 25;
52797
+ var REQUEST_TIMEOUT_MS2 = 6e4;
52798
+ var blobsQuery = (count2) => {
52799
+ const indexes = Array.from({ length: count2 }, (_, i2) => i2);
52800
+ const variables = indexes.map((i2) => `$e${i2}: String!`).join(", ");
52801
+ const objects = indexes.map(
52802
+ (i2) => `f${i2}: object(expression: $e${i2}) { __typename ... on Blob { text isBinary isTruncated } }`
52803
+ ).join("\n ");
52804
+ return `query ($owner: String!, $repo: String!, ${variables}) {
52805
+ repository(owner: $owner, name: $repo) {
52806
+ ${objects}
52807
+ }
52808
+ }`;
52809
+ };
52741
52810
  var createGitHubHost = (options) => {
52742
52811
  const { clock, logger } = options;
52812
+ const timeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS2;
52743
52813
  const octokit = new Octokit2({
52744
52814
  auth: options.token,
52745
52815
  userAgent: `megareviewer/${options.version}`,
@@ -52753,23 +52823,44 @@ var createGitHubHost = (options) => {
52753
52823
  error: (message) => logger.debug(message)
52754
52824
  }
52755
52825
  });
52756
- const read = async (what, schema, send) => {
52826
+ const read = async (what, schema, send, { deadline = true } = {}) => {
52827
+ const controller = deadline ? new AbortController() : null;
52828
+ const timer = controller === null ? null : setTimeout(() => controller.abort(), timeoutMs);
52757
52829
  let data;
52758
52830
  try {
52759
- data = await send();
52831
+ data = await send(controller === null ? {} : { signal: controller.signal });
52760
52832
  } catch (error62) {
52833
+ if (controller?.signal.aborted) return err2(timedOut2(timeoutMs));
52761
52834
  return err2(fromOctokitError(error62, clock.now()));
52835
+ } finally {
52836
+ if (timer !== null) clearTimeout(timer);
52762
52837
  }
52763
52838
  const parsed = schema.safeParse(data);
52764
52839
  return parsed.success ? ok(parsed.data) : err2(invalidResponse2(what, parsed.error));
52765
52840
  };
52841
+ const fileOverRest = async (repo, { sha, path: path4 }) => {
52842
+ const encodedPath = path4.split("/").map(encodeURIComponent).join("/");
52843
+ const content = await read(`${path4} at ${sha}`, GitHubText, async (request2) => {
52844
+ return (await octokit.request(`GET /repos/{owner}/{repo}/contents/${encodedPath}`, {
52845
+ owner: repo.owner,
52846
+ repo: repo.repo,
52847
+ ref: sha,
52848
+ headers: { accept: RAW },
52849
+ request: request2
52850
+ })).data;
52851
+ });
52852
+ if (!content.ok && (content.error.kind === "not-found" || content.error.kind === "too-large")) {
52853
+ return ok(null);
52854
+ }
52855
+ return content;
52856
+ };
52766
52857
  let cachedUser = null;
52767
52858
  return {
52768
52859
  host: "github",
52769
52860
  async currentUser() {
52770
52861
  if (cachedUser !== null) return ok(cachedUser);
52771
- const user = await read("the current user", GitHubUser, async () => {
52772
- return (await octokit.request("GET /user")).data;
52862
+ const user = await read("the current user", GitHubUser, async (request2) => {
52863
+ return (await octokit.request("GET /user", { request: request2 })).data;
52773
52864
  });
52774
52865
  if (!user.ok) return user;
52775
52866
  cachedUser = toHostUser2(user.value);
@@ -52778,39 +52869,43 @@ var createGitHubHost = (options) => {
52778
52869
  async getPullRequest(ref) {
52779
52870
  const { owner, repo } = ref.repo;
52780
52871
  const pull_number = ref.number;
52781
- const pullRequest = await read("the pull request", GitHubPullRequest, async () => {
52872
+ const pullRequest = await read("the pull request", GitHubPullRequest, async (request2) => {
52782
52873
  return (await octokit.request("GET /repos/{owner}/{repo}/pulls/{pull_number}", {
52783
52874
  owner,
52784
52875
  repo,
52785
- pull_number
52876
+ pull_number,
52877
+ request: request2
52786
52878
  })).data;
52787
52879
  });
52788
52880
  if (!pullRequest.ok) return pullRequest;
52789
52881
  const basehead = `${pullRequest.value.base.sha}...${pullRequest.value.head.sha}`;
52790
52882
  const [diff, comparison, commits] = await Promise.all([
52791
- read("the diff", GitHubText, async () => {
52883
+ read("the diff", GitHubText, async (request2) => {
52792
52884
  return (await octokit.request("GET /repos/{owner}/{repo}/compare/{basehead}", {
52793
52885
  owner,
52794
52886
  repo,
52795
52887
  basehead,
52796
- headers: { accept: DIFF }
52888
+ headers: { accept: DIFF },
52889
+ request: request2
52797
52890
  })).data;
52798
52891
  }),
52799
- read("the merge base", GitHubComparison, async () => {
52892
+ read("the merge base", GitHubComparison, async (request2) => {
52800
52893
  return (await octokit.request("GET /repos/{owner}/{repo}/compare/{basehead}", {
52801
52894
  owner,
52802
52895
  repo,
52803
- basehead
52896
+ basehead,
52897
+ request: request2
52804
52898
  })).data;
52805
52899
  }),
52806
52900
  read(
52807
52901
  "the commits",
52808
52902
  external_exports.array(GitHubCommit),
52809
- () => octokit.paginate("GET /repos/{owner}/{repo}/pulls/{pull_number}/commits", {
52903
+ (request2) => octokit.paginate(octokit.rest.pulls.listCommits, {
52810
52904
  owner,
52811
52905
  repo,
52812
52906
  pull_number,
52813
- per_page: 100
52907
+ per_page: 100,
52908
+ request: request2
52814
52909
  })
52815
52910
  )
52816
52911
  ]);
@@ -52830,24 +52925,42 @@ var createGitHubHost = (options) => {
52830
52925
  })
52831
52926
  );
52832
52927
  },
52833
- async getFile(repo, sha, path4) {
52834
- const encodedPath = path4.split("/").map(encodeURIComponent).join("/");
52835
- const content = await read(`${path4} at ${sha}`, GitHubText, async () => {
52836
- return (await octokit.request(`GET /repos/{owner}/{repo}/contents/${encodedPath}`, {
52837
- owner: repo.owner,
52838
- repo: repo.repo,
52839
- ref: sha,
52840
- headers: { accept: RAW }
52841
- })).data;
52842
- });
52843
- if (!content.ok && content.error.kind === "not-found") return ok(null);
52844
- return content;
52928
+ async getFiles(repo, files) {
52929
+ const found = [];
52930
+ for (let start3 = 0; start3 < files.length; start3 += FILES_PER_QUERY) {
52931
+ const chunk = files.slice(start3, start3 + FILES_PER_QUERY);
52932
+ const blobs = await read(
52933
+ `${chunk.length} files`,
52934
+ GitHubBlobs,
52935
+ (request2) => octokit.graphql(blobsQuery(chunk.length), {
52936
+ owner: repo.owner,
52937
+ repo: repo.repo,
52938
+ ...Object.fromEntries(chunk.map(({ sha, path: path4 }, i2) => [`e${i2}`, `${sha}:${path4}`])),
52939
+ request: request2
52940
+ })
52941
+ );
52942
+ if (!blobs.ok) return blobs;
52943
+ for (const [i2, file2] of chunk.entries()) {
52944
+ const object2 = blobs.value.repository[`f${i2}`];
52945
+ if (object2 === null) {
52946
+ found.push(null);
52947
+ } else if (object2?.__typename === "Blob" && typeof object2.text === "string" && object2.isBinary !== true && object2.isTruncated !== true) {
52948
+ found.push(object2.text);
52949
+ } else {
52950
+ const content = await fileOverRest(repo, file2);
52951
+ if (!content.ok) return content;
52952
+ found.push(content.value);
52953
+ }
52954
+ }
52955
+ }
52956
+ return ok(found);
52845
52957
  },
52846
52958
  async listPullRequests() {
52847
- const search = (qualifier) => read("the pull request search", GitHubSearchResult, async () => {
52959
+ const search = (qualifier) => read("the pull request search", GitHubSearchResult, async (request2) => {
52848
52960
  return (await octokit.request("GET /search/issues", {
52849
52961
  q: `is:pr is:open archived:false ${qualifier}`,
52850
- per_page: SEARCH_PAGE_SIZE
52962
+ per_page: SEARCH_PAGE_SIZE,
52963
+ request: request2
52851
52964
  })).data;
52852
52965
  });
52853
52966
  const [reviewing, authored] = await Promise.all([
@@ -52880,24 +52993,33 @@ var createGitHubHost = (options) => {
52880
52993
  if (plan === null) return ok({ url: null, publishedAt: clock.now(), postedCommentIds: {} });
52881
52994
  const { owner, repo } = ref.repo;
52882
52995
  const pull_number = ref.number;
52883
- const review = await read("the new review", GitHubReview, async () => {
52884
- return (await octokit.request("POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews", {
52885
- owner,
52886
- repo,
52887
- pull_number,
52888
- ...plan.request,
52889
- comments: [...plan.request.comments]
52890
- })).data;
52891
- });
52996
+ const review = await read(
52997
+ "the new review",
52998
+ GitHubReview,
52999
+ async () => {
53000
+ return (await octokit.request("POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews", {
53001
+ owner,
53002
+ repo,
53003
+ pull_number,
53004
+ ...plan.request,
53005
+ comments: [...plan.request.comments]
53006
+ })).data;
53007
+ },
53008
+ { deadline: false }
53009
+ );
52892
53010
  if (!review.ok) return review;
52893
53011
  const reviewId = String(review.value.id);
52894
53012
  const hostComments = plan.comments.length === 0 ? ok([]) : await read(
52895
53013
  "the review's comments",
52896
53014
  external_exports.array(GitHubReviewComment),
52897
- () => octokit.paginate(
52898
- "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments",
52899
- { owner, repo, pull_number, review_id: review.value.id, per_page: 100 }
52900
- )
53015
+ (request2) => octokit.paginate(octokit.rest.pulls.listCommentsForReview, {
53016
+ owner,
53017
+ repo,
53018
+ pull_number,
53019
+ review_id: review.value.id,
53020
+ per_page: 100,
53021
+ request: request2
53022
+ })
52901
53023
  );
52902
53024
  if (!hostComments.ok) {
52903
53025
  logger.warn("Couldn't read the review's comments back; recording the review's ID", {
@@ -56972,6 +57094,16 @@ var MIGRATIONS = [
56972
57094
  published_at TEXT NOT NULL,
56973
57095
  receipt TEXT NOT NULL -- PublicationReceipt as JSON
56974
57096
  );
57097
+ `,
57098
+ `
57099
+ CREATE TABLE file_contents (
57100
+ host TEXT NOT NULL, owner TEXT NOT NULL, repo TEXT NOT NULL,
57101
+ sha TEXT NOT NULL, path TEXT NOT NULL,
57102
+ content TEXT, -- NULL: the file isn't at this commit
57103
+ fetched_at TEXT NOT NULL,
57104
+ PRIMARY KEY (host, owner, repo, sha, path)
57105
+ );
57106
+ CREATE INDEX file_contents_fetched_at ON file_contents (fetched_at);
56975
57107
  `
56976
57108
  ];
56977
57109
  var UserVersion = external_exports.object({ user_version: external_exports.number().int().nonnegative() });
@@ -57006,6 +57138,7 @@ var migrate = (db, migrations) => {
57006
57138
  };
57007
57139
 
57008
57140
  // src/adapters/sqlite/sqlite-analysis-store.ts
57141
+ var FILE_CACHE_DAYS = 30;
57009
57142
  var PayloadRow = external_exports.object({ id: external_exports.string(), payload: external_exports.string() });
57010
57143
  var VerdictRow = external_exports.object({
57011
57144
  decision_id: external_exports.string(),
@@ -57015,6 +57148,7 @@ var VerdictRow = external_exports.object({
57015
57148
  });
57016
57149
  var PostedCommentRow = external_exports.object({ comment_key: external_exports.string(), host_comment_id: external_exports.string() });
57017
57150
  var ReceiptRow = external_exports.object({ receipt: external_exports.string() });
57151
+ var FileRow = external_exports.object({ content: external_exports.string().nullable() });
57018
57152
  var parseJson3 = (text) => {
57019
57153
  try {
57020
57154
  return JSON.parse(text);
@@ -57075,6 +57209,20 @@ var openSqliteAnalysisStore = (options) => {
57075
57209
  ON CONFLICT (analysis_id, comment_key) DO UPDATE SET
57076
57210
  host_comment_id = excluded.host_comment_id, posted_at = excluded.posted_at`
57077
57211
  );
57212
+ const selectFile = db.prepare(
57213
+ `SELECT content FROM file_contents
57214
+ WHERE host = ? AND owner = ? AND repo = ? AND sha = ? AND path = ?`
57215
+ );
57216
+ const upsertFile = db.prepare(
57217
+ `INSERT INTO file_contents (host, owner, repo, sha, path, content, fetched_at)
57218
+ VALUES (?, ?, ?, ?, ?, ?, ?)
57219
+ ON CONFLICT (host, owner, repo, sha, path) DO UPDATE SET
57220
+ content = excluded.content, fetched_at = excluded.fetched_at`
57221
+ );
57222
+ const pruned = db.prepare("DELETE FROM file_contents WHERE fetched_at < ?").run(new Date(Date.parse(clock.now()) - FILE_CACHE_DAYS * 24 * 60 * 60 * 1e3).toISOString());
57223
+ if (Number(pruned.changes) > 0) {
57224
+ logger.debug("Dropped old cached files", { count: Number(pruned.changes) });
57225
+ }
57078
57226
  const selectPublication = db.prepare("SELECT receipt FROM publications WHERE analysis_id = ?");
57079
57227
  const upsertPublication = db.prepare(
57080
57228
  `INSERT INTO publications (analysis_id, url, published_at, receipt)
@@ -57184,6 +57332,13 @@ var openSqliteAnalysisStore = (options) => {
57184
57332
  async recordPublication(id, receipt) {
57185
57333
  upsertPublication.run(id, receipt.url, receipt.publishedAt, JSON.stringify(receipt));
57186
57334
  },
57335
+ async get(repo, { sha, path: path4 }) {
57336
+ const row = selectFile.get(repo.host, repo.owner, repo.repo, sha, path4);
57337
+ return row === void 0 ? null : { content: FileRow.parse(row).content };
57338
+ },
57339
+ async put(repo, { sha, path: path4 }, content) {
57340
+ upsertFile.run(repo.host, repo.owner, repo.repo, sha, path4, content, clock.now());
57341
+ },
57187
57342
  close() {
57188
57343
  if (!open2) return;
57189
57344
  db.close();
@@ -61432,6 +61587,33 @@ var createAnalysisEvents = () => {
61432
61587
  };
61433
61588
  };
61434
61589
 
61590
+ // src/application/retries/retrying-git-host.ts
61591
+ var RETRY_DELAYS_MS = [1e3, 2e3, 4e3];
61592
+ var retryingGitHost = (host, { sleep: sleep3, logger }) => {
61593
+ const retried = async (what, attempt) => {
61594
+ let result = await attempt();
61595
+ for (const delayMs of RETRY_DELAYS_MS) {
61596
+ if (result.ok || result.error.kind !== "network") return result;
61597
+ logger.warn(`Retrying ${what} after a network error`, {
61598
+ host: host.host,
61599
+ error: result.error.message,
61600
+ delayMs
61601
+ });
61602
+ await sleep3(delayMs);
61603
+ result = await attempt();
61604
+ }
61605
+ return result;
61606
+ };
61607
+ return {
61608
+ host: host.host,
61609
+ currentUser: () => retried("the current user", () => host.currentUser()),
61610
+ getPullRequest: (ref) => retried("the pull request", () => host.getPullRequest(ref)),
61611
+ getFiles: (repo, files) => retried(`${files.length} files`, () => host.getFiles(repo, files)),
61612
+ listPullRequests: () => retried("the pull request list", () => host.listPullRequests()),
61613
+ publish: (ref, publication, options) => host.publish(ref, publication, options)
61614
+ };
61615
+ };
61616
+
61435
61617
  // src/application/use-cases/errors.ts
61436
61618
  var appError = (code2, message) => ({ code: code2, message });
61437
61619
  var fromHostError = (error62) => appError(error62.kind === "not-found" ? "not-found" : "host-error", error62.message);
@@ -61456,9 +61638,11 @@ var mapLimit2 = async (items, limit2, map2) => {
61456
61638
  };
61457
61639
 
61458
61640
  // src/application/use-cases/collect.ts
61459
- var FETCH_CONCURRENCY2 = 8;
61641
+ var FILES_PER_BATCH = 10;
61642
+ var BATCH_CONCURRENCY = 2;
61460
61643
  var MAX_FILE_BYTES = 1024 * 1024;
61461
- var collect = async (deps, host, pullRequest) => {
61644
+ var collect = async (deps, host, pullRequest, onFilesFetched = async () => {
61645
+ }) => {
61462
61646
  const parsed = parseUnifiedDiff(pullRequest.diff);
61463
61647
  if (!parsed.ok) {
61464
61648
  const { line, message } = parsed.error;
@@ -61471,17 +61655,6 @@ var collect = async (deps, host, pullRequest) => {
61471
61655
  language: deps.parser.languageFor(file2.path)
61472
61656
  }));
61473
61657
  const { repo } = pullRequest.ref;
61474
- const fetchFile = async (sha, path4) => {
61475
- const result = await host.getFile(repo, sha, path4);
61476
- if (result.ok) return ok(fitsSize(result.value));
61477
- if (result.error.kind === "too-large") return ok(null);
61478
- return err2(
61479
- appError(
61480
- "host-error",
61481
- `Couldn't fetch ${path4} at ${sha.slice(0, 7)}: ${result.error.message}`
61482
- )
61483
- );
61484
- };
61485
61658
  const fetches = [
61486
61659
  { side: "attributes", sha: pullRequest.baseSha, path: ".gitattributes" },
61487
61660
  ...files.filter((file2) => !file2.binary).flatMap((file2) => [
@@ -61489,10 +61662,35 @@ var collect = async (deps, host, pullRequest) => {
61489
61662
  ...file2.status === "deleted" ? [] : [{ side: "head", sha: pullRequest.headSha, path: file2.path }]
61490
61663
  ])
61491
61664
  ];
61492
- const fetched = await mapLimit2(fetches, FETCH_CONCURRENCY2, async (fetch2) => ({
61493
- ...fetch2,
61494
- content: await fetchFile(fetch2.sha, fetch2.path)
61495
- }));
61665
+ const cached2 = await Promise.all(fetches.map((fetch2) => deps.fileCache.get(repo, fetch2)));
61666
+ const missing = fetches.map((fetch2, index) => ({ ...fetch2, index })).filter(({ index }) => cached2[index] === null);
61667
+ const batches = Array.from(
61668
+ { length: Math.ceil(missing.length / FILES_PER_BATCH) },
61669
+ (_, i2) => missing.slice(i2 * FILES_PER_BATCH, (i2 + 1) * FILES_PER_BATCH)
61670
+ );
61671
+ const fetched = /* @__PURE__ */ new Map();
61672
+ let done = fetches.length - missing.length;
61673
+ await onFilesFetched({ done, total: fetches.length });
61674
+ const failures = await mapLimit2(batches, BATCH_CONCURRENCY, async (batch) => {
61675
+ const contents = await host.getFiles(repo, batch);
61676
+ if (!contents.ok) return contents.error;
61677
+ for (const [i2, file2] of batch.entries()) {
61678
+ const content = contents.value[i2] ?? null;
61679
+ fetched.set(file2.index, content);
61680
+ if (content === null || fitsSize(content) !== null) {
61681
+ await deps.fileCache.put(repo, file2, content);
61682
+ }
61683
+ }
61684
+ done += batch.length;
61685
+ await onFilesFetched({ done, total: fetches.length });
61686
+ return null;
61687
+ });
61688
+ const failure2 = failures.find((error62) => error62 !== null) ?? null;
61689
+ if (failure2 !== null) {
61690
+ return err2(
61691
+ appError("host-error", `Couldn't fetch the pull request's files: ${failure2.message}`)
61692
+ );
61693
+ }
61496
61694
  let gitattributes = null;
61497
61695
  const baseContents = /* @__PURE__ */ new Map();
61498
61696
  const headContents = /* @__PURE__ */ new Map();
@@ -61500,10 +61698,10 @@ var collect = async (deps, host, pullRequest) => {
61500
61698
  if (file2.status === "added") baseContents.set(file2.path, null);
61501
61699
  if (file2.status === "deleted") headContents.set(file2.path, null);
61502
61700
  }
61503
- for (const { side, path: path4, content } of fetched) {
61504
- if (!content.ok) return content;
61505
- if (side === "attributes") gitattributes = content.value;
61506
- else (side === "base" ? baseContents : headContents).set(path4, content.value);
61701
+ for (const [index, { side, path: path4 }] of fetches.entries()) {
61702
+ const content = fitsSize(cached2[index]?.content ?? fetched.get(index) ?? null);
61703
+ if (side === "attributes") gitattributes = content;
61704
+ else (side === "base" ? baseContents : headContents).set(path4, content);
61507
61705
  }
61508
61706
  return ok({
61509
61707
  files,
@@ -61828,6 +62026,10 @@ var createTracker = (deps, initial) => {
61828
62026
  }));
61829
62027
  deps.events.emit(analysis.id, { type: "updated", stage, status });
61830
62028
  };
62029
+ const setFilesFetched = async (filesFetched) => {
62030
+ await save({ filesFetched });
62031
+ deps.events.emit(analysis.id, { type: "updated", stage: "collect", status: "running" });
62032
+ };
61831
62033
  const finish = async (error62) => {
61832
62034
  await save({ status: error62 === null ? "complete" : "failed", error: error62 });
61833
62035
  deps.events.emit(analysis.id, { type: "done" });
@@ -61838,6 +62040,7 @@ var createTracker = (deps, initial) => {
61838
62040
  },
61839
62041
  save,
61840
62042
  setStage,
62043
+ setFilesFetched,
61841
62044
  finish,
61842
62045
  fail: async (stage, failure2) => {
61843
62046
  await setStage(stage, "failed");
@@ -61864,7 +62067,7 @@ var runPipeline = async (deps, host, analysis, pullRequest) => {
61864
62067
  var stages = async (deps, host, pullRequest, tracker) => {
61865
62068
  const { model, logger } = deps;
61866
62069
  await tracker.setStage("collect", "running");
61867
- const collected = await collect(deps, host, pullRequest);
62070
+ const collected = await collect(deps, host, pullRequest, tracker.setFilesFetched);
61868
62071
  if (!collected.ok) return tracker.fail("collect", collected.error);
61869
62072
  const { files, tickets, missingTicketKeys } = collected.value;
61870
62073
  await tracker.setStage("collect", "done", (analysis) => ({
@@ -61986,6 +62189,7 @@ var newAnalysis = (deps, key, pullRequest, createdAt) => ({
61986
62189
  decisions: "pending",
61987
62190
  tour: "pending"
61988
62191
  },
62192
+ filesFetched: null,
61989
62193
  pullRequest,
61990
62194
  files: [],
61991
62195
  tickets: [],
@@ -62248,12 +62452,15 @@ var NO_ANTHROPIC_CREDENTIALS = "No Anthropic credentials found. Set ANTHROPIC_AP
62248
62452
  var createHosts = (config2, options) => {
62249
62453
  const { github, bitbucket } = config2;
62250
62454
  const { clock, logger } = options;
62455
+ const retrying = (host) => retryingGitHost(host, { sleep: sleep2, logger });
62251
62456
  return {
62252
62457
  ...github !== null && {
62253
- github: createGitHubHost({ token: github.token, version: options.version, clock, logger })
62458
+ github: retrying(
62459
+ createGitHubHost({ token: github.token, version: options.version, clock, logger })
62460
+ )
62254
62461
  },
62255
62462
  ...bitbucket !== null && {
62256
- bitbucket: createBitbucketHost({ ...bitbucket, clock, logger })
62463
+ bitbucket: retrying(createBitbucketHost({ ...bitbucket, clock, logger }))
62257
62464
  }
62258
62465
  };
62259
62466
  };
@@ -62324,6 +62531,7 @@ var compose2 = async (config2, options) => {
62324
62531
  model: modelConfigured ? claude : unconfiguredModel(claude.version),
62325
62532
  modelConfigured,
62326
62533
  store,
62534
+ fileCache: store,
62327
62535
  parser: await createTreeSitterCodeParser({ grammarsDir: options.grammarsDir }),
62328
62536
  clock,
62329
62537
  logger