Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
d2745e0
wip on reposv2 table
brendan-kellam Aug 18, 2026
38fe67b
remove old repos table
brendan-kellam Aug 18, 2026
e0b027a
wip
brendan-kellam Aug 18, 2026
d7de6d1
add banner
brendan-kellam Aug 18, 2026
1fd7291
remove repository carousel
brendan-kellam Aug 18, 2026
127c1e7
add example questions to chat page
brendan-kellam Aug 18, 2026
f3c4f3b
remove repo indexing job table
brendan-kellam Aug 18, 2026
13f12c3
add clear filter button
brendan-kellam Aug 18, 2026
18d1ff3
add first sync banner
brendan-kellam Aug 18, 2026
cd323f3
remove permission job tables
brendan-kellam Aug 18, 2026
82d4408
rename connection workload
brendan-kellam Aug 18, 2026
f3e63af
remove connection sync notification dot
brendan-kellam Aug 18, 2026
61cfd7b
workload job return type plumbing
brendan-kellam Aug 18, 2026
066690c
add concept of repositoryDiscoveryIssueContext
brendan-kellam Aug 18, 2026
e3242ee
connections table
brendan-kellam Aug 19, 2026
9435e16
replace existing connections table & rework what 'warning' means
brendan-kellam Aug 19, 2026
663325d
improve first time syncing banner
brendan-kellam Aug 19, 2026
203869d
change status badge behaviour in repos table subtly
brendan-kellam Aug 19, 2026
191435d
connection sync issue banner
brendan-kellam Aug 19, 2026
51b9243
remove connection job table
brendan-kellam Aug 19, 2026
f9ae84b
connection progress banner
brendan-kellam Aug 19, 2026
7bab7c7
add clear filter button
brendan-kellam Aug 19, 2026
3367852
migrate other hosts to using report function
brendan-kellam Aug 19, 2026
3efad84
changelog
brendan-kellam Aug 19, 2026
9aec1f6
Merge branch 'main' into bkellam/job-ui-v2
brendan-kellam Aug 19, 2026
b9805f0
fix tests
brendan-kellam Aug 19, 2026
36bc8e9
feedback
brendan-kellam Aug 19, 2026
81b8f03
feedback
brendan-kellam Aug 19, 2026
4a3b6b4
feedback
brendan-kellam Aug 19, 2026
17a3564
feedback
brendan-kellam Aug 19, 2026
e2a0e8f
feedback
brendan-kellam Aug 19, 2026
03be1ee
move repo cleanup into sepreate queue with shared lock
brendan-kellam Aug 19, 2026
3924d57
added additional deduplication behaviour
brendan-kellam Aug 19, 2026
61d9044
add retry all button to repository table
brendan-kellam Aug 19, 2026
310c5c1
improve connection sync repo removal behaviour
brendan-kellam Aug 19, 2026
71c7987
Merge branch 'main' into bkellam/job-ui-v2
brendan-kellam Aug 19, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed
- 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)
- 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)

### Fixed
- 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)
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ Use the workload system in `packages/backend` for background work. Define the qu

### Lifecycle state

- 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.
- 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.
- 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.
- If a parent resource tracks a `latest...JobId`, update that pointer when the job starts so consumers can resolve its state from BullMQ.
- 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.
- `onTerminalFailure` only runs after the job exhausts all retry attempts. Intermediate failures are retried without marking the lifecycle row as terminally failed.
- 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.
- 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.
Expand Down
4 changes: 1 addition & 3 deletions packages/backend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from '@bull-board/metrics';
import { Octokit } from '@octokit/rest';
import * as Sentry from "@sentry/node";
import { PrismaClient, RepoIndexingJobType } from '@sourcebot/db';
import { PrismaClient } from '@sourcebot/db';
import { createLogger, env, JOB_PRIORITIES } from '@sourcebot/shared';
import express, { NextFunction, Request, Response } from 'express';
import 'express-async-errors';
Expand Down Expand Up @@ -176,7 +176,6 @@ const scheduleAndTriggerRepoIndexing = async ({
reindexIntervalMs,
{
repoId,
type: RepoIndexingJobType.INDEX,
},
{ priority: JOB_PRIORITIES.SCHEDULED },
);
Expand All @@ -185,7 +184,6 @@ const scheduleAndTriggerRepoIndexing = async ({
"repo-index",
{
repoId,
type: RepoIndexingJobType.INDEX,
},
{ priority: JOB_PRIORITIES.INTERACTIVE },
);
Expand Down
8 changes: 1 addition & 7 deletions packages/backend/src/attachmentPruneWorkload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,6 @@ interface Props {
storage?: StorageBackend;
}

interface AttachmentPruneResult {
pendingClaimed: number;
committedClaimed: number;
reclaimed: number;
}

/**
* Reclaims orphaned attachment blobs using the `DELETING` tombstone protocol:
* an orphan is first atomically flipped to `DELETING`, then its bytes are
Expand All @@ -47,7 +41,7 @@ export const createAttachmentPruneWorkload = ({
db,
ttlHours,
storage = getStorageBackend(),
}: Props): Workload<"attachment-prune", AttachmentPruneResult> => ({
}: Props): Workload<"attachment-prune"> => ({
queueSpec: ATTACHMENT_PRUNE_QUEUE,
concurrency: 1,
...(ttlHours > 0
Expand Down
137 changes: 137 additions & 0 deletions packages/backend/src/azuredevops.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/azuredevops.type';
import { beforeEach, describe, expect, test, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
getProjects: vi.fn(),
getRepositories: vi.fn(),
getRepository: vi.fn(),
}));

vi.mock("@sentry/node", () => ({
captureException: vi.fn(),
}));

vi.mock("@sourcebot/shared", async (importOriginal) => ({
...await importOriginal<typeof import("@sourcebot/shared")>(),
getTokenFromConfig: vi.fn(async () => "token"),
}));

vi.mock("azure-devops-node-api", () => ({
getPersonalAccessTokenHandler: vi.fn(() => ({})),
WebApi: class {
getCoreApi = vi.fn(async () => ({
getProjects: mocks.getProjects,
}));
getGitApi = vi.fn(async () => ({
getRepositories: mocks.getRepositories,
getRepository: mocks.getRepository,
}));
},
}));

vi.mock("./utils.js", () => ({
fetchWithRetry: (routine: () => Promise<unknown>) => routine(),
measure: async (routine: () => Promise<unknown>) => ({
durationMs: 1,
data: await routine(),
}),
}));

import { getAzureDevOpsReposFromConfig } from './azuredevops';
import { collectRepositoryDiscoveryIssues } from './repositoryDiscoveryIssueContext.js';

const config = (overrides: Partial<AzureDevOpsConnectionConfig>): AzureDevOpsConnectionConfig => ({
type: "azuredevops",
deploymentType: "cloud",
token: { env: "AZURE_DEVOPS_TOKEN" },
...overrides,
});

beforeEach(() => {
vi.clearAllMocks();
const notFound = Object.assign(new Error("Not Found"), { statusCode: 404 });
mocks.getProjects.mockRejectedValue(notFound);
mocks.getRepositories.mockRejectedValue(notFound);
mocks.getRepository.mockRejectedValue(notFound);
});

describe("Azure DevOps repository discovery", () => {
test("reports inaccessible configured targets as partial successes", async () => {
const result = await collectRepositoryDiscoveryIssues(() =>
getAzureDevOpsReposFromConfig(config({
orgs: ["missing-org"],
projects: ["org/missing-project"],
repos: ["org/project/missing-repo"],
}))
);

expect(result).toEqual({
value: [],
issues: [
{
code: "NOT_FOUND_OR_INACCESSIBLE",
effect: "TARGET_SKIPPED",
subject: {
kind: "organization",
value: "missing-org",
},
message: "Azure DevOps organization was not found or is inaccessible.",
},
{
code: "NOT_FOUND_OR_INACCESSIBLE",
effect: "TARGET_SKIPPED",
subject: {
kind: "project",
value: "org/missing-project",
},
message: "Azure DevOps project was not found or is inaccessible.",
},
{
code: "NOT_FOUND_OR_INACCESSIBLE",
effect: "TARGET_SKIPPED",
subject: {
kind: "repository",
value: "org/project/missing-repo",
},
message: "Azure DevOps repository was not found or is inaccessible.",
},
],
});
});

test("reports incomplete project enumeration within an organization", async () => {
mocks.getProjects.mockResolvedValue([
{ name: "missing-id" },
{ id: "broken-project-id", name: "broken-project" },
]);
mocks.getRepositories.mockRejectedValue(new Error("Service unavailable"));

const result = await collectRepositoryDiscoveryIssues(() =>
getAzureDevOpsReposFromConfig(config({ orgs: ["my-org"] }))
);

expect(result).toEqual({
value: [],
issues: [
{
code: "INVALID_PROVIDER_RESPONSE",
effect: "DISCOVERY_INCOMPLETE",
subject: {
kind: "project",
value: "my-org/missing-id",
},
message: "Azure DevOps returned a project without an ID, so its repositories were skipped.",
},
{
code: "ENUMERATION_FAILED",
effect: "DISCOVERY_INCOMPLETE",
subject: {
kind: "project",
value: "my-org/broken-project",
},
message: "Azure DevOps repository enumeration did not complete for this project.",
},
],
});
});
});
Loading
Loading