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
4 changes: 2 additions & 2 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ Unlike the Pull Requests tab (which groups by repo), the Dependencies tab groups
| **Needs Action** | CI pending, checks still running, or PR is a draft — not yet actionable |
| **Stale** | PR has been open more than 14 days without merging — may need a rebase or manual review |

Within each group, PRs are sorted by repository name, then update category (maintenance, pin, patch, minor, major), then update date.
Within each group, PRs are sorted by repository name, then update category (maintenance, pin, digest, patch, minor, major), then update date.

### Abandoned Dependencies

Expand All @@ -323,7 +323,7 @@ Go to **Settings > Dependencies** to configure:

| Filter | Options | Default |
|--------|---------|---------|
| Update type | All / Major / Minor / Patch | All |
| Update type | All / Major / Minor / Patch / Digest | All |
| Bot | All / (detected bot logins) | All (shown when multiple bots are active) |

The update type filter reads the PR title for SemVer version bump signals (e.g., `1.x → 2.x` = Major). PRs with titles that do not contain recognizable version patterns are grouped under the currently active filter if it is set to All.
Expand Down
15 changes: 13 additions & 2 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1209,21 +1209,30 @@ export default function DashboardPage() {
// classification survives page refresh without visual jank.
createEffect(() => {
if (!config.dependencies.enabled) return;
if (_fetchingDepBodies) return;
if (_fetchingDepBodies) {
console.debug("[dashboard] depBodies effect: skipped — fetch already in flight (this run's tracked deps are now narrowed to config.dependencies.enabled only)");
return;
}
const octokit = getClient();
if (!octokit) return;

const meta = depMeta();
const depPrs = dependencyPullRequests();
const visibleDepPrs = visibleDependencyPullRequests();
const toFetch = visibleDepPrs.filter((pr) => !meta.has(pr.id) && needsBodyFallback(pr));
if (toFetch.length === 0) return;
if (toFetch.length === 0) {
console.debug("[dashboard] depBodies effect: nothing to fetch", { metaSize: meta.size, visibleDepPrCount: visibleDepPrs.length });
return;
}

_fetchingDepBodies = true;
const effectStart = Date.now();
console.debug(`[dashboard] depBodies effect: fetch started for ${toFetch.length} PRs at ${effectStart}`);
void (async () => {
try {
const nodeIds = toFetch.map((pr) => pr.nodeId!);
const bodyMap = await fetchDepPRBodies(octokit, nodeIds);
console.debug(`[dashboard] depBodies effect: fetch resolved after ${Date.now() - effectStart}ms`, { requested: toFetch.length, returned: bodyMap.size });
if (bodyMap.size === 0) return;

const merged = new Map(meta);
Expand All @@ -1237,9 +1246,11 @@ export default function DashboardPage() {
if (!depPrIds.has(k)) merged.delete(k);
}
setDepMeta(merged);
console.debug(`[dashboard] depBodies effect: depMeta updated, size=${merged.size}`);
setTimeout(() => persistDepMeta(merged), 0);
} finally {
_fetchingDepBodies = false;
console.debug(`[dashboard] depBodies effect: guard released after ${Date.now() - effectStart}ms`);
}
})();
});
Expand Down
25 changes: 13 additions & 12 deletions src/app/components/dashboard/DependenciesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const UPDATE_TYPE_OPTIONS: FilterChipGroupDef = {
options: [
{ value: "maintenance", label: "Maintenance" },
{ value: "pin", label: "Pin" },
{ value: "digest", label: "Digest" },
{ value: "patch", label: "Patch" },
{ value: "minor", label: "Minor" },
{ value: "major", label: "Major" },
Expand All @@ -48,42 +49,42 @@ const STATUS_META: Record<DepStatus, { label: string }> = {
"stale": { label: "Stale" },
};

type DepCategory = "major" | "minor" | "patch" | "pin" | "maintenance" | "other";

function mapUpdateType(ut: NonNullable<VersionInfo["updateType"]>): DepCategory {
if (ut === "digest") return "patch";
return ut;
}
type DepCategory = "major" | "minor" | "patch" | "digest" | "pin" | "maintenance" | "other";

const CATEGORY_SORT_ORDER: Record<DepCategory, number> = {
maintenance: 0,
pin: 1,
patch: 2,
minor: 3,
major: 4,
other: 5,
digest: 2,
patch: 3,
minor: 4,
major: 5,
other: 6,
};

const CATEGORY_BADGE_CLASS: Partial<Record<DepCategory, string>> = {
major: "badge-error",
minor: "badge-warning",
patch: "badge-success",
pin: "badge-success",
digest: "badge-success",
};

function depCategory(pr: PullRequest, versionInfo: VersionInfo | null): DepCategory {
if (versionInfo?.updateType) return mapUpdateType(versionInfo.updateType);
if (versionInfo?.updateType) return versionInfo.updateType;

const titleLower = pr.title.toLowerCase();
if (/pin\s+dep/.test(titleLower)) return "pin";
if (/lock\s*file\s+maintenance/.test(titleLower)) return "maintenance";

const fallback: DepCategory = versionInfo ? "other" : "maintenance";
const fallback: DepCategory = "other";
for (const l of pr.labels) {
const name = l.name.toLowerCase();
if (name === "major") return "major";
if (name === "minor") return "minor";
if (name === "patch") return "patch";
if (name === "digest") return "digest";
if (name === "pin") return "pin";
if (name === "maintenance") return "maintenance";
}
return fallback;
}
Expand Down
18 changes: 18 additions & 0 deletions src/app/lib/dependency-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,30 @@ export function extractVersionInfo(title: string): VersionInfo | null {
return { packageName: actionMatch[1]!, to: actionMatch[2]! };
}

// "Update (rust) crate X to vY"
const crateMatch = /^Update\s+(?:rust\s+)?crate\s+(.+?)\s+to\s+(v?[\w.\-+]+)/i.exec(body);
if (crateMatch && /^v?\d/.test(crateMatch[2]!)) {
return { packageName: crateMatch[1]!, to: crateMatch[2]! };
}

// "Update X Docker tag to vY"
const dockerMatch = /^Update\s+(.+?)\s+docker\s+tag\s+to\s+(v?[\w.\-+]+)/i.exec(body);
if (dockerMatch && /^v?\d/.test(dockerMatch[2]!)) {
return { packageName: dockerMatch[1]!, to: dockerMatch[2]! };
}

// Generic "from A to B" anywhere
const genericMatch = /\bfrom\s+([\w.\-+]+)\s+to\s+([\w.\-+]+)/i.exec(body);
if (genericMatch) {
return { from: genericMatch[1]!, to: genericMatch[2]!, updateType: semverUpdateType(genericMatch[1]!, genericMatch[2]!) ?? undefined };
}

// Generic "Update X to vY" (last resort, single-target version only)
const singleTargetMatch = /^Update\s+(.+?)\s+to\s+(v?[\w.\-+]+)$/i.exec(body);
if (singleTargetMatch && /^v?\d/.test(singleTargetMatch[2]!)) {
return { packageName: singleTargetMatch[1]!, to: singleTargetMatch[2]! };
}

return null;
}

Expand Down
69 changes: 63 additions & 6 deletions src/app/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,29 @@ export async function fetchPREnrichment(
return { enrichments, errors };
}

// Shared timeout guard for body-fetch GraphQL calls. Prevents a hung request
// (e.g. octokit's secondary-rate-limit retry logic stalling indefinitely)
// from wedging the caller's fetch-in-progress gate.

export const GRAPHQL_BODY_FETCH_TIMEOUT_MS = 20_000;

export class GraphqlFetchTimeoutError extends Error {}

export function raceWithTimeout<T>(promise: Promise<T>, ms: number, controller: AbortController): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;

const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
controller.abort();
reject(new GraphqlFetchTimeoutError(`GraphQL request exceeded ${ms}ms`));
}, ms);
});

return Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutId);
});
}

// ── Dashboard issue body fetch ────────────────────────────────────────────────

const DASHBOARD_ISSUE_BODIES_QUERY = `
Expand All @@ -1187,27 +1210,44 @@ export async function fetchDashboardIssueBodies(
if (issueNodeIds.length === 0) return result;

const batches = chunkArray(issueNodeIds, NODES_BATCH_SIZE);
let hadFailure = false;
await Promise.allSettled(batches.map(async (batch) => {
const batchStart = Date.now();
console.debug(`[api] dashboardBodies batch started (${batch.length} ids) at ${batchStart}`);
const controller = new AbortController();
try {
const response = await octokit.graphql<DashboardIssueBodiesResponse>(
DASHBOARD_ISSUE_BODIES_QUERY,
{ ids: batch, request: { apiSource: "dashboardBodies" } }
const response = await raceWithTimeout(
octokit.graphql<DashboardIssueBodiesResponse>(
DASHBOARD_ISSUE_BODIES_QUERY,
{ ids: batch, request: { apiSource: "dashboardBodies", signal: controller.signal } }
),
GRAPHQL_BODY_FETCH_TIMEOUT_MS,
controller,
);
if (response.rateLimit) updateGraphqlRateLimit(response.rateLimit);
for (const node of response.nodes) {
if (!node || !node.id) continue;
result.set(node.id, node.body);
}
} catch (err) {
hadFailure = true;
console.warn("[api] dashboardBodies batch failed or timed out:", err);
Sentry.captureException(err, { tags: { source: "dashboardBodies" } });
const partialErr =
err && typeof err === "object" && "data" in err && err.data && typeof err.data === "object"
? (err.data as Partial<DashboardIssueBodiesResponse>)
: null;
if (partialErr?.rateLimit) updateGraphqlRateLimit(partialErr.rateLimit);
// Partial failures return null bodies — callers handle missing entries gracefully
} finally {
console.debug(`[api] dashboardBodies batch settled after ${Date.now() - batchStart}ms`);
}
}));

if (hadFailure && getClient() === octokit) {
pushNotification("dashboardBodies", "Some dependency dashboard data could not be loaded", "warning");
}

return result;
}

Expand Down Expand Up @@ -1235,26 +1275,43 @@ export async function fetchDepPRBodies(
if (prNodeIds.length === 0) return result;

const batches = chunkArray(prNodeIds, NODES_BATCH_SIZE);
let hadFailure = false;
await Promise.allSettled(batches.map(async (batch) => {
const batchStart = Date.now();
console.debug(`[api] depPRBodies batch started (${batch.length} ids) at ${batchStart}`);
const controller = new AbortController();
try {
const response = await octokit.graphql<DepPRBodiesResponse>(
DEP_PR_BODIES_QUERY,
{ ids: batch, request: { apiSource: "depPRBodies" } }
const response = await raceWithTimeout(
octokit.graphql<DepPRBodiesResponse>(
DEP_PR_BODIES_QUERY,
{ ids: batch, request: { apiSource: "depPRBodies", signal: controller.signal } }
),
GRAPHQL_BODY_FETCH_TIMEOUT_MS,
controller,
);
if (response.rateLimit) updateGraphqlRateLimit(response.rateLimit);
for (const node of response.nodes) {
if (!node || node.databaseId == null || !node.body) continue;
result.set(node.databaseId, node.body);
}
} catch (err) {
hadFailure = true;
console.warn("[api] depPRBodies batch failed or timed out:", err);
Sentry.captureException(err, { tags: { source: "depPRBodies" } });
const partialErr =
err && typeof err === "object" && "data" in err && err.data && typeof err.data === "object"
? (err.data as Partial<DepPRBodiesResponse>)
: null;
if (partialErr?.rateLimit) updateGraphqlRateLimit(partialErr.rateLimit);
} finally {
console.debug(`[api] depPRBodies batch settled after ${Date.now() - batchStart}ms`);
}
}));

if (hadFailure && getClient() === octokit) {
pushNotification("depPRBodies", "Some dependency PR types could not be determined — badges may be missing", "warning");
}

return result;
}

Expand Down
2 changes: 1 addition & 1 deletion src/app/stores/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const ActionsFiltersSchema = z.object({
});

export const DependencyFiltersSchema = z.object({
updateType: z.enum(["all", "major", "minor", "patch", "pin", "maintenance", "other"]).default("all"),
updateType: z.enum(["all", "major", "minor", "patch", "pin", "digest", "maintenance", "other"]).default("all"),
bot: z.string().default("all"),
});

Expand Down
94 changes: 94 additions & 0 deletions tests/components/dashboard/DependenciesTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,100 @@ describe("DependenciesTab — bot filter", () => {
});
});

// ── Update type filter options order ─────────────────────────────────────────

describe("DependenciesTab — update type filter options order", () => {
it("shows options in risk order: Maintenance, Pin, Digest, Patch, Minor, Major, Other", () => {
vi.useFakeTimers();
try {
const pr = makeMergeablePR();
renderTab({ pullRequests: [pr] });
const trigger = screen.getByRole("button", { name: /filter by update type/i });
fireEvent.click(trigger);
vi.advanceTimersByTime(0);
const content = document.querySelector('[aria-label="Update type"]')!;
const optionLabels = Array.from(content.querySelectorAll("button"))
.map((b) => b.textContent?.replace(/^✓\s*/, "").trim())
.filter((t): t is string => !!t && t !== "All");
expect(optionLabels).toEqual(["Maintenance", "Pin", "Digest", "Patch", "Minor", "Major", "Other"]);
} finally {
vi.useRealTimers();
}
});
});

// ── Category classification ───────────────────────────────────────────────────

describe("DependenciesTab — category classification", () => {
it("digest category sorts between pin and patch, and renders a visible badge", () => {
const prPin = makeMergeablePR({ id: 7001, title: "chore(deps): pin dependencies" });
const prDigest = makeMergeablePR({ id: 7002, title: "chore(deps): update rust crate pyo3 to v0.29.1" });
const prPatch = makeMergeablePR({ id: 7003, title: "Bump axios from 0.27.1 to 0.27.2" });
const depMeta = new Map([
[prDigest.id, { updateType: "digest" as const, packageName: "pyo3", to: "v0.29.1" }],
]);

renderTab({ pullRequests: [prPatch, prPin, prDigest], depMeta });

expect(screen.getByText("digest")).toBeDefined();

const items = screen.getAllByRole("listitem");
const categories = items
.map((item) => item.querySelector(".badge")?.textContent?.trim())
.filter((c): c is string => !!c);
expect(categories).toEqual(["pin", "digest", "patch"]);
});

it("PR with unparseable title and no matching labels renders as 'other' (hidden badge), not 'maintenance'", () => {
const pr = makeMergeablePR({ title: "chore(deps): refresh vendored dependencies" });
renderTab({ pullRequests: [pr] });
expect(screen.queryByText("maintenance")).toBeNull();
expect(screen.queryByText("other")).toBeNull();

const item = screen.getByRole("listitem");
expect(item.querySelector(".badge")).toBeNull();
});

it("PR with a lock-file-maintenance title still renders as 'maintenance' (regression guard)", () => {
const pr = makeMergeablePR({ title: "chore(deps): lock file maintenance" });
renderTab({ pullRequests: [pr] });
expect(screen.getByText("maintenance")).toBeDefined();
});

it("PR with unparseable title and a 'digest' label renders category 'digest' with a visible badge", () => {
const pr = makeMergeablePR({
title: "chore(deps): refresh vendored dependencies",
labels: [{ name: "digest", color: "1a7f37" }],
});
renderTab({ pullRequests: [pr] });

const item = screen.getByRole("listitem");
expect(item.querySelector(".badge")?.textContent?.trim()).toBe("digest");
});

it("PR with unparseable title and a 'pin' label renders category 'pin' with a visible badge", () => {
const pr = makeMergeablePR({
title: "chore(deps): refresh vendored dependencies",
labels: [{ name: "pin", color: "1a7f37" }],
});
renderTab({ pullRequests: [pr] });

const item = screen.getByRole("listitem");
expect(item.querySelector(".badge")?.textContent?.trim()).toBe("pin");
});

it("PR with unparseable title and a 'maintenance' label renders category 'maintenance' with a visible badge", () => {
const pr = makeMergeablePR({
title: "chore(deps): refresh vendored dependencies",
labels: [{ name: "maintenance", color: "1a7f37" }],
});
renderTab({ pullRequests: [pr] });

const item = screen.getByRole("listitem");
expect(item.querySelector(".badge")?.textContent?.trim()).toBe("maintenance");
});
});

// ── Label filtering ──────────────────────────────────────────────────────────

describe("DependenciesTab — label filtering", () => {
Expand Down
Loading