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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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