Skip to content
Merged
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
26 changes: 12 additions & 14 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import DependenciesTab from "./DependenciesTab";
import { isDependencyPr, expandBotLogins, needsBodyFallback, parseRenovateBody, type VersionInfo } from "../../lib/dependency-detection";
import { isRepoExcludedFromDependencies } from "../../lib/dependency-exclusion";
import { findDashboardIssues, parseAbandonedSection, resetAbandonedPatternCache, type AbandonedDependency } from "../../lib/dependency-dashboard";
import { fetchDashboardIssueBodies, fetchDepPRBodies } from "../../services/api";
import { fetchDashboardIssueBodies, fetchDepPRBodies, fallbackToPreviousEnrichment, pickEnrichmentFields } from "../../services/api";
import type { SortOption } from "../shared/SortDropdown";
import type { Issue, PullRequest, WorkflowRun } from "../../services/api";
import { fetchOrgs } from "../../services/api";
Expand Down Expand Up @@ -285,23 +285,20 @@ async function pollFetch(): Promise<DashboardData> {
for (let i = 0; i < state.pullRequests.length; i++) {
const e = enrichedMap.get(state.pullRequests[i].id)!;
const pr = state.pullRequests[i];
pr.headSha = e.headSha;
pr.assigneeLogins = e.assigneeLogins;
pr.reviewerLogins = e.reviewerLogins;
pr.checkStatus = e.checkStatus;
pr.additions = e.additions;
pr.deletions = e.deletions;
pr.changedFiles = e.changedFiles;
pr.comments = e.comments;
pr.reviewThreads = e.reviewThreads;
pr.totalReviewCount = e.totalReviewCount;
pr.enriched = e.enriched;
// A failed backfill batch returns e.enriched === false for PRs it
// couldn't reach — don't let that regress a PR that was already
// enriched from a prior cycle.
const regressing = e.enriched === false && pr.enriched !== false;
if (!regressing) {
Object.assign(pr, pickEnrichmentFields(e));
pr.enriched = e.enriched;
}
pr.nodeId = e.nodeId;
pr.surfacedBy = e.surfacedBy;
pr.starCount = e.starCount;
}
} else {
state.pullRequests = data.pullRequests;
state.pullRequests = fallbackToPreviousEnrichment(state.pullRequests, data.pullRequests);
}
}));
} else {
Expand All @@ -310,10 +307,11 @@ async function pollFetch(): Promise<DashboardData> {
// changed since the last cycle. Preserve scroll position: SolidJS
// DOM updates are synchronous within the setter, so save/restore
// around it to prevent scroll reset from <For> DOM rebuild.
const pullRequests = fallbackToPreviousEnrichment(dashboardData.pullRequests, data.pullRequests);
withScrollLock(() => {
setDashboardData({
issues: data.issues,
pullRequests: data.pullRequests,
pullRequests,
workflowRuns: config.enableActions ? data.workflowRuns : [],
loading: false,
lastRefreshedAt: now,
Expand Down
74 changes: 64 additions & 10 deletions src/app/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,8 @@ export async function fetchPREnrichment(
updateGraphqlRateLimit(partialErr.rateLimit);
}
const { statusCode, message } = extractRejectionError(err);
console.warn(`[api] PR enrichment batch ${batchIdx + 1}/${batches.length} failed:`, err);
Sentry.captureException(err, { tags: { source: "prEnrichment" } });
errors.push({
repo: `backfill-batch-${batchIdx + 1}/${batches.length}`,
statusCode, message,
Expand Down Expand Up @@ -1327,6 +1329,42 @@ export async function fetchDepPRBodies(
);
}

/**
* The heavy PR fields populated by phase-2 enrichment. Centralized so the sites
* that copy enrichment onto a PR — mergeEnrichment, fallbackToPreviousEnrichment,
* and the fine-grained store merge in DashboardPage — stay in sync when a field
* is added or removed.
*/
type EnrichmentFields = Pick<
PullRequest,
| "headSha"
| "assigneeLogins"
| "reviewerLogins"
| "checkStatus"
| "additions"
| "deletions"
| "changedFiles"
| "comments"
| "reviewThreads"
| "totalReviewCount"
>;

/** Extracts just the heavy enrichment fields from any PR-shaped source. */
export function pickEnrichmentFields(source: EnrichmentFields): EnrichmentFields {
return {
headSha: source.headSha,
assigneeLogins: source.assigneeLogins,
reviewerLogins: source.reviewerLogins,
checkStatus: source.checkStatus,
additions: source.additions,
deletions: source.deletions,
changedFiles: source.changedFiles,
comments: source.comments,
reviewThreads: source.reviewThreads,
totalReviewCount: source.totalReviewCount,
};
}

/**
* Merges phase 2 enrichment data into light PRs. Returns enriched PR array.
* Also detects fork PRs for the statusCheckRollup fallback.
Expand All @@ -1346,16 +1384,32 @@ function mergeEnrichment(

return {
...pr,
headSha: e.headSha,
assigneeLogins: e.assigneeLogins,
reviewerLogins: e.reviewerLogins,
checkStatus: e.checkStatus,
additions: e.additions,
deletions: e.deletions,
changedFiles: e.changedFiles,
comments: e.comments,
reviewThreads: e.reviewThreads,
totalReviewCount: e.totalReviewCount,
...pickEnrichmentFields(e),
enriched: true,
};
});
}

/**
* Carries forward a PR's last-known enrichment when this cycle's backfill
* failed for it, instead of regressing an already-enriched PR to unenriched.
* Without this, a single transient backfill failure wipes size/check-status
* data and can flip dependency-status classification (e.g. Mergeable ->
* Needs Action) until the next successful poll re-enriches it.
*/
export function fallbackToPreviousEnrichment(
previous: PullRequest[],
next: PullRequest[]
): PullRequest[] {
if (previous.length === 0) return next;
const previousMap = new Map(previous.map((pr) => [pr.id, pr]));
return next.map((pr) => {
if (pr.enriched !== false) return pr;
const prev = previousMap.get(pr.id);
if (!prev || prev.enriched === false) return pr;
return {
...pr,
...pickEnrichmentFields(prev),
enriched: true,
};
});
Expand Down
130 changes: 130 additions & 0 deletions tests/services/api-optimization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import {
fetchIssuesAndPullRequests,
fetchWorkflowRuns,
fetchPREnrichment,
fallbackToPreviousEnrichment,
pickEnrichmentFields,
type RepoRef,
} from "../../src/app/services/api";
import { clearCache } from "../../src/app/stores/cache";
import { makePullRequest } from "../helpers/factories";

vi.mock("../../src/app/lib/errors", () => ({
pushNotification: vi.fn(),
Expand Down Expand Up @@ -597,3 +600,130 @@ describe("fetchPREnrichment mergeStateStatus UNSTABLE override", () => {
expect(enrichments.get(100)!.checkStatus).toBe("failure");
});
});

// ── Enrichment carry-forward on backfill failure ──────────────────────────────

describe("fallbackToPreviousEnrichment", () => {
it("returns next unchanged when previous is empty", () => {
const next = [makePullRequest({ id: 1, enriched: false })];
const result = fallbackToPreviousEnrichment([], next);
expect(result).toBe(next);
});

it("passes through a PR that is already enriched in next (no carry-forward)", () => {
const previous = [makePullRequest({ id: 1, enriched: true, checkStatus: "failure" })];
const next = [makePullRequest({ id: 1, enriched: true, checkStatus: "success" })];
const result = fallbackToPreviousEnrichment(previous, next);
// Fresh enriched data wins — prior "failure" must not overwrite fresh "success".
expect(result[0].checkStatus).toBe("success");
expect(result[0]).toBe(next[0]);
});

it("passes through an unenriched next PR with no matching previous entry", () => {
const previous = [makePullRequest({ id: 99, enriched: true })];
const next = [makePullRequest({ id: 1, enriched: false })];
const result = fallbackToPreviousEnrichment(previous, next);
expect(result[0]).toBe(next[0]);
expect(result[0].enriched).toBe(false);
});

it("passes through an unenriched next PR whose previous entry was also unenriched", () => {
const previous = [makePullRequest({ id: 1, enriched: false })];
const next = [makePullRequest({ id: 1, enriched: false })];
const result = fallbackToPreviousEnrichment(previous, next);
expect(result[0]).toBe(next[0]);
expect(result[0].enriched).toBe(false);
});

it("carries forward prior enrichment for an unenriched next PR that was enriched before", () => {
const previous = [
makePullRequest({
id: 1,
enriched: true,
checkStatus: "success",
additions: 42,
deletions: 7,
changedFiles: 3,
comments: 5,
reviewThreads: 2,
totalReviewCount: 4,
reviewerLogins: ["reviewer1"],
assigneeLogins: ["assignee1"],
headSha: "prevsha",
}),
];
const next = [
makePullRequest({
id: 1,
enriched: false,
state: "OPEN",
title: "Fresh title",
checkStatus: null,
additions: 0,
deletions: 0,
changedFiles: 0,
comments: 0,
reviewThreads: 0,
totalReviewCount: 0,
reviewerLogins: [],
assigneeLogins: [],
headSha: "",
}),
];
const result = fallbackToPreviousEnrichment(previous, next);
// Heavy fields restored from the prior cycle...
expect(result[0].enriched).toBe(true);
expect(result[0].checkStatus).toBe("success");
expect(result[0].additions).toBe(42);
expect(result[0].deletions).toBe(7);
expect(result[0].changedFiles).toBe(3);
expect(result[0].comments).toBe(5);
expect(result[0].reviewThreads).toBe(2);
expect(result[0].totalReviewCount).toBe(4);
expect(result[0].reviewerLogins).toEqual(["reviewer1"]);
expect(result[0].assigneeLogins).toEqual(["assignee1"]);
expect(result[0].headSha).toBe("prevsha");
// ...but fresh light fields survive.
expect(result[0].title).toBe("Fresh title");
});

it("drops a PR that closed (present in previous, absent from next)", () => {
const previous = [
makePullRequest({ id: 1, enriched: true }),
makePullRequest({ id: 2, enriched: true }),
];
const next = [makePullRequest({ id: 1, enriched: false })];
const result = fallbackToPreviousEnrichment(previous, next);
// Only the PR still in `next` remains — the closed PR (id 2) is not resurrected.
expect(result.map((pr) => pr.id)).toEqual([1]);
});
});

describe("pickEnrichmentFields", () => {
it("returns only the heavy enrichment fields, not light fields", () => {
const pr = makePullRequest({
id: 1,
title: "light title",
state: "OPEN",
checkStatus: "success",
additions: 10,
reviewerLogins: ["r1"],
});
const fields = pickEnrichmentFields(pr);
expect(fields).toEqual({
headSha: pr.headSha,
assigneeLogins: pr.assigneeLogins,
reviewerLogins: pr.reviewerLogins,
checkStatus: pr.checkStatus,
additions: pr.additions,
deletions: pr.deletions,
changedFiles: pr.changedFiles,
comments: pr.comments,
reviewThreads: pr.reviewThreads,
totalReviewCount: pr.totalReviewCount,
});
expect(fields).not.toHaveProperty("title");
expect(fields).not.toHaveProperty("state");
expect(fields).not.toHaveProperty("enriched");
});
});