Skip to content

Commit 8a24ac6

Browse files
feat(worker, web): job ui v2 (#1608)
* wip on reposv2 table * remove old repos table * wip * add banner * remove repository carousel * add example questions to chat page * remove repo indexing job table * add clear filter button * add first sync banner * remove permission job tables * rename connection workload * remove connection sync notification dot * workload job return type plumbing * add concept of repositoryDiscoveryIssueContext * connections table * replace existing connections table & rework what 'warning' means * improve first time syncing banner * change status badge behaviour in repos table subtly * connection sync issue banner * remove connection job table * connection progress banner * add clear filter button * migrate other hosts to using report function * changelog * fix tests * feedback * feedback * feedback * feedback * feedback * move repo cleanup into sepreate queue with shared lock * added additional deduplication behaviour * add retry all button to repository table * improve connection sync repo removal behaviour
1 parent 7ac2682 commit 8a24ac6

122 files changed

Lines changed: 9071 additions & 4353 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212

1313
### Changed
1414
- Migrated connection syncing, repository indexing, permission syncing, and background pruning from in-process managers and pollers to BullMQ workloads with retries and per-resource execution locking. [#1427](https://github.com/sourcebot-dev/sourcebot/pull/1427)
15+
- Refactored the repository and connection list view to display useful debugging information to owners, such as failure reasons and logs. [#1608](https://github.com/sourcebot-dev/sourcebot/pull/1608)
1516

1617
### Fixed
1718
- Upgraded Next.js to 16.3.1 to bound memory retained by high-cardinality dynamic route cache entries. [#1594](https://github.com/sourcebot-dev/sourcebot/pull/1594)

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ Use the workload system in `packages/backend` for background work. Define the qu
3232

3333
### Lifecycle state
3434

35-
- In `onStarted`, upsert the workload-specific job row as `IN_PROGRESS`. If the parent resource tracks a `latest...JobId`, update that pointer in the same database transaction.
36-
- Completion and terminal-failure hooks must always update their own historical job row by job ID. Do not condition that update on the job still being latest. Every job row should record its actual outcome.
35+
- BullMQ is the default source of truth for workload lifecycle state. Only persist a separate database job row when the product explicitly requires durable history beyond the queue's retention policy.
36+
- If a parent resource tracks a `latest...JobId`, update that pointer when the job starts so consumers can resolve its state from BullMQ.
37+
- For workloads that persist historical job rows, completion and terminal-failure hooks must always update their own row by job ID. Do not condition that update on the job still being latest. Every persisted job row should record its actual outcome.
3738
- `onTerminalFailure` only runs after the job exhausts all retry attempts. Intermediate failures are retried without marking the lifecycle row as terminally failed.
3839
- If a completion or failure hook publishes state onto the parent resource, use a conditional `updateMany` keyed by both the resource ID and its `latest...JobId`. This prevents an older hook from overwriting state belonging to a newer job after the execution lock has been released.
3940
- Parent-resource state written inside `process` is already serialized by the execution lock. It does not need a latest-job conditional merely because the resource tracks the latest job ID.

packages/backend/src/api.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from '@bull-board/metrics';
88
import { Octokit } from '@octokit/rest';
99
import * as Sentry from "@sentry/node";
10-
import { PrismaClient, RepoIndexingJobType } from '@sourcebot/db';
10+
import { PrismaClient } from '@sourcebot/db';
1111
import { createLogger, env, JOB_PRIORITIES } from '@sourcebot/shared';
1212
import express, { NextFunction, Request, Response } from 'express';
1313
import 'express-async-errors';
@@ -176,7 +176,6 @@ const scheduleAndTriggerRepoIndexing = async ({
176176
reindexIntervalMs,
177177
{
178178
repoId,
179-
type: RepoIndexingJobType.INDEX,
180179
},
181180
{ priority: JOB_PRIORITIES.SCHEDULED },
182181
);
@@ -185,7 +184,6 @@ const scheduleAndTriggerRepoIndexing = async ({
185184
"repo-index",
186185
{
187186
repoId,
188-
type: RepoIndexingJobType.INDEX,
189187
},
190188
{ priority: JOB_PRIORITIES.INTERACTIVE },
191189
);

packages/backend/src/attachmentPruneWorkload.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,6 @@ interface Props {
1818
storage?: StorageBackend;
1919
}
2020

21-
interface AttachmentPruneResult {
22-
pendingClaimed: number;
23-
committedClaimed: number;
24-
reclaimed: number;
25-
}
26-
2721
/**
2822
* Reclaims orphaned attachment blobs using the `DELETING` tombstone protocol:
2923
* an orphan is first atomically flipped to `DELETING`, then its bytes are
@@ -47,7 +41,7 @@ export const createAttachmentPruneWorkload = ({
4741
db,
4842
ttlHours,
4943
storage = getStorageBackend(),
50-
}: Props): Workload<"attachment-prune", AttachmentPruneResult> => ({
44+
}: Props): Workload<"attachment-prune"> => ({
5145
queueSpec: ATTACHMENT_PRUNE_QUEUE,
5246
concurrency: 1,
5347
...(ttlHours > 0
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import type { AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/azuredevops.type';
2+
import { beforeEach, describe, expect, test, vi } from 'vitest';
3+
4+
const mocks = vi.hoisted(() => ({
5+
getProjects: vi.fn(),
6+
getRepositories: vi.fn(),
7+
getRepository: vi.fn(),
8+
}));
9+
10+
vi.mock("@sentry/node", () => ({
11+
captureException: vi.fn(),
12+
}));
13+
14+
vi.mock("@sourcebot/shared", async (importOriginal) => ({
15+
...await importOriginal<typeof import("@sourcebot/shared")>(),
16+
getTokenFromConfig: vi.fn(async () => "token"),
17+
}));
18+
19+
vi.mock("azure-devops-node-api", () => ({
20+
getPersonalAccessTokenHandler: vi.fn(() => ({})),
21+
WebApi: class {
22+
getCoreApi = vi.fn(async () => ({
23+
getProjects: mocks.getProjects,
24+
}));
25+
getGitApi = vi.fn(async () => ({
26+
getRepositories: mocks.getRepositories,
27+
getRepository: mocks.getRepository,
28+
}));
29+
},
30+
}));
31+
32+
vi.mock("./utils.js", () => ({
33+
fetchWithRetry: (routine: () => Promise<unknown>) => routine(),
34+
measure: async (routine: () => Promise<unknown>) => ({
35+
durationMs: 1,
36+
data: await routine(),
37+
}),
38+
}));
39+
40+
import { getAzureDevOpsReposFromConfig } from './azuredevops';
41+
import { collectRepositoryDiscoveryIssues } from './repositoryDiscoveryIssueContext.js';
42+
43+
const config = (overrides: Partial<AzureDevOpsConnectionConfig>): AzureDevOpsConnectionConfig => ({
44+
type: "azuredevops",
45+
deploymentType: "cloud",
46+
token: { env: "AZURE_DEVOPS_TOKEN" },
47+
...overrides,
48+
});
49+
50+
beforeEach(() => {
51+
vi.clearAllMocks();
52+
const notFound = Object.assign(new Error("Not Found"), { statusCode: 404 });
53+
mocks.getProjects.mockRejectedValue(notFound);
54+
mocks.getRepositories.mockRejectedValue(notFound);
55+
mocks.getRepository.mockRejectedValue(notFound);
56+
});
57+
58+
describe("Azure DevOps repository discovery", () => {
59+
test("reports inaccessible configured targets as partial successes", async () => {
60+
const result = await collectRepositoryDiscoveryIssues(() =>
61+
getAzureDevOpsReposFromConfig(config({
62+
orgs: ["missing-org"],
63+
projects: ["org/missing-project"],
64+
repos: ["org/project/missing-repo"],
65+
}))
66+
);
67+
68+
expect(result).toEqual({
69+
value: [],
70+
issues: [
71+
{
72+
code: "NOT_FOUND_OR_INACCESSIBLE",
73+
effect: "TARGET_SKIPPED",
74+
subject: {
75+
kind: "organization",
76+
value: "missing-org",
77+
},
78+
message: "Azure DevOps organization was not found or is inaccessible.",
79+
},
80+
{
81+
code: "NOT_FOUND_OR_INACCESSIBLE",
82+
effect: "TARGET_SKIPPED",
83+
subject: {
84+
kind: "project",
85+
value: "org/missing-project",
86+
},
87+
message: "Azure DevOps project was not found or is inaccessible.",
88+
},
89+
{
90+
code: "NOT_FOUND_OR_INACCESSIBLE",
91+
effect: "TARGET_SKIPPED",
92+
subject: {
93+
kind: "repository",
94+
value: "org/project/missing-repo",
95+
},
96+
message: "Azure DevOps repository was not found or is inaccessible.",
97+
},
98+
],
99+
});
100+
});
101+
102+
test("reports incomplete project enumeration within an organization", async () => {
103+
mocks.getProjects.mockResolvedValue([
104+
{ name: "missing-id" },
105+
{ id: "broken-project-id", name: "broken-project" },
106+
]);
107+
mocks.getRepositories.mockRejectedValue(new Error("Service unavailable"));
108+
109+
const result = await collectRepositoryDiscoveryIssues(() =>
110+
getAzureDevOpsReposFromConfig(config({ orgs: ["my-org"] }))
111+
);
112+
113+
expect(result).toEqual({
114+
value: [],
115+
issues: [
116+
{
117+
code: "INVALID_PROVIDER_RESPONSE",
118+
effect: "DISCOVERY_INCOMPLETE",
119+
subject: {
120+
kind: "project",
121+
value: "my-org/missing-id",
122+
},
123+
message: "Azure DevOps returned a project without an ID, so its repositories were skipped.",
124+
},
125+
{
126+
code: "ENUMERATION_FAILED",
127+
effect: "DISCOVERY_INCOMPLETE",
128+
subject: {
129+
kind: "project",
130+
value: "my-org/broken-project",
131+
},
132+
message: "Azure DevOps repository enumeration did not complete for this project.",
133+
},
134+
],
135+
});
136+
});
137+
});

0 commit comments

Comments
 (0)