Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile.worker
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ FROM zbigniew1/niu-browser-base:latest AS production
WORKDIR /app

# Install pnpm and squint globally (some repos use pnpm, squint for codebase analysis)
RUN npm install -g pnpm @zbigniewsobiecki/squint@^1.7.0 --force
RUN npm install -g pnpm @zbigniewsobiecki/squint@^1.10.2 --force

# Install additional tools not in niu-browser-base
# Note: PostgreSQL is NOT installed - workers connect to external PostgreSQL
Expand Down
2 changes: 2 additions & 0 deletions src/api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { agentConfigsRouter } from './routers/agentConfigs.js';
import { authRouter } from './routers/auth.js';
import { credentialsRouter } from './routers/credentials.js';
import { defaultsRouter } from './routers/defaults.js';
import { integrationsDiscoveryRouter } from './routers/integrationsDiscovery.js';
import { organizationRouter } from './routers/organization.js';
import { projectsRouter } from './routers/projects.js';
import { promptsRouter } from './routers/prompts.js';
Expand All @@ -21,6 +22,7 @@ export const appRouter = router({
prompts: promptsRouter,
webhooks: webhooksRouter,
webhookLogs: webhookLogsRouter,
integrationsDiscovery: integrationsDiscoveryRouter,
});

export type AppRouter = typeof appRouter;
183 changes: 183 additions & 0 deletions src/api/routers/integrationsDiscovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { TRPCError } from '@trpc/server';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { getDb } from '../../db/client.js';
import { decryptCredential } from '../../db/crypto.js';
import { credentials } from '../../db/schema/index.js';
import { jiraClient, withJiraCredentials } from '../../jira/client.js';
import { trelloClient, withTrelloCredentials } from '../../trello/client.js';
import { logger } from '../../utils/logging.js';
import { protectedProcedure, router } from '../trpc.js';

async function resolveCredentialValue(credentialId: number, orgId: string): Promise<string> {
const db = getDb();
const [cred] = await db
.select({ orgId: credentials.orgId, value: credentials.value })
.from(credentials)
.where(eq(credentials.id, credentialId));
if (!cred || cred.orgId !== orgId) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Credential ${credentialId} not found` });
}
return decryptCredential(cred.value, cred.orgId);
}

const trelloCredsInput = z.object({
apiKeyCredentialId: z.number(),
tokenCredentialId: z.number(),
});

const jiraCredsInput = z.object({
emailCredentialId: z.number(),
apiTokenCredentialId: z.number(),
baseUrl: z.string().url(),
});

async function resolveTrelloCreds(input: z.infer<typeof trelloCredsInput>, orgId: string) {
const [apiKey, token] = await Promise.all([
resolveCredentialValue(input.apiKeyCredentialId, orgId),
resolveCredentialValue(input.tokenCredentialId, orgId),
]);
return { apiKey, token };
}

async function resolveJiraCreds(input: z.infer<typeof jiraCredsInput>, orgId: string) {
const [email, apiToken] = await Promise.all([
resolveCredentialValue(input.emailCredentialId, orgId),
resolveCredentialValue(input.apiTokenCredentialId, orgId),
]);
return { email, apiToken, baseUrl: input.baseUrl };
}

export const integrationsDiscoveryRouter = router({
verifyTrello: protectedProcedure.input(trelloCredsInput).mutation(async ({ ctx, input }) => {
logger.debug('integrationsDiscovery.verifyTrello called', { orgId: ctx.effectiveOrgId });
const creds = await resolveTrelloCreds(input, ctx.effectiveOrgId);

try {
const me = await withTrelloCredentials(creds, () => trelloClient.getMe());
return { id: me.id, fullName: me.fullName, username: me.username };
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Failed to verify Trello credentials: ${err instanceof Error ? err.message : String(err)}`,
});
}
}),

verifyJira: protectedProcedure.input(jiraCredsInput).mutation(async ({ ctx, input }) => {
logger.debug('integrationsDiscovery.verifyJira called', { orgId: ctx.effectiveOrgId });
const creds = await resolveJiraCreds(input, ctx.effectiveOrgId);

try {
const me = await withJiraCredentials(creds, () => jiraClient.getMyself());
return {
displayName: (me as { displayName?: string }).displayName ?? '',
emailAddress: (me as { emailAddress?: string }).emailAddress ?? '',
accountId: (me as { accountId?: string }).accountId ?? '',
};
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Failed to verify JIRA credentials: ${err instanceof Error ? err.message : String(err)}`,
});
}
}),

trelloBoards: protectedProcedure.input(trelloCredsInput).mutation(async ({ ctx, input }) => {
logger.debug('integrationsDiscovery.trelloBoards called', { orgId: ctx.effectiveOrgId });
const creds = await resolveTrelloCreds(input, ctx.effectiveOrgId);

try {
return await withTrelloCredentials(creds, () => trelloClient.getBoards());
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Failed to fetch Trello boards: ${err instanceof Error ? err.message : String(err)}`,
});
}
}),

trelloBoardDetails: protectedProcedure
.input(
trelloCredsInput.extend({
boardId: z
.string()
.regex(/^[a-zA-Z0-9]+$/)
.max(32),
}),
)
.mutation(async ({ ctx, input }) => {
logger.debug('integrationsDiscovery.trelloBoardDetails called', {
orgId: ctx.effectiveOrgId,
boardId: input.boardId,
});
const creds = await resolveTrelloCreds(input, ctx.effectiveOrgId);

try {
const [lists, labels, customFields] = await withTrelloCredentials(creds, () =>
Promise.all([
trelloClient.getBoardLists(input.boardId),
trelloClient.getBoardLabels(input.boardId),
trelloClient.getBoardCustomFields(input.boardId),
]),
);
return { lists, labels, customFields };
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Failed to fetch Trello board details: ${err instanceof Error ? err.message : String(err)}`,
});
}
}),

jiraProjects: protectedProcedure.input(jiraCredsInput).mutation(async ({ ctx, input }) => {
logger.debug('integrationsDiscovery.jiraProjects called', { orgId: ctx.effectiveOrgId });
const creds = await resolveJiraCreds(input, ctx.effectiveOrgId);

try {
return await withJiraCredentials(creds, () => jiraClient.searchProjects());
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Failed to fetch JIRA projects: ${err instanceof Error ? err.message : String(err)}`,
});
}
}),

jiraProjectDetails: protectedProcedure
.input(
jiraCredsInput.extend({
projectKey: z
.string()
.regex(/^[A-Z][A-Z0-9_]+$/)
.max(10),
}),
)
.mutation(async ({ ctx, input }) => {
logger.debug('integrationsDiscovery.jiraProjectDetails called', {
orgId: ctx.effectiveOrgId,
projectKey: input.projectKey,
});
const creds = await resolveJiraCreds(input, ctx.effectiveOrgId);

try {
const [statuses, issueTypes, fields] = await withJiraCredentials(creds, () =>
Promise.all([
jiraClient.getProjectStatuses(input.projectKey),
jiraClient.getIssueTypesForProject(input.projectKey),
jiraClient.getFields(),
]),
);
return {
statuses,
issueTypes,
fields: fields.filter((f) => f.custom),
};
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Failed to fetch JIRA project details: ${err instanceof Error ? err.message : String(err)}`,
});
}
}),
});
36 changes: 30 additions & 6 deletions src/backends/progressMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,13 @@ import { getSessionState } from '../gadgets/sessionState.js';
import { loadTodos } from '../gadgets/todo/storage.js';
import { githubClient } from '../github/client.js';
import { getPMProviderOrNull } from '../pm/index.js';
import { captureException } from '../sentry.js';
import { type ProgressContext, callProgressModel } from './progressModel.js';
import { clearProgressCommentId, writeProgressCommentId } from './progressState.js';
import {
clearProgressCommentId,
readProgressCommentId,
writeProgressCommentId,
} from './progressState.js';
import type { LogWriter, ProgressReporter } from './types.js';

export interface ProgressMonitorConfig {
Expand Down Expand Up @@ -48,6 +53,7 @@ export interface ProgressMonitorConfig {
/** Default progressive schedule: 1min, 3min, 5min, then every intervalMinutes */
const DEFAULT_SCHEDULE_MINUTES = [1, 3, 5];

const PROGRESS_MODEL_TIMEOUT_MS = 20_000;
const RING_BUFFER_MAX = 20;
const TEXT_SNIPPETS_MAX = 10;
const COMPLETED_TASKS_MAX = 5;
Expand Down Expand Up @@ -274,11 +280,15 @@ export class ProgressMonitor implements ProgressReporter {

let summary: string;
try {
summary = await callProgressModel(
this.config.progressModel,
progressContext,
this.config.customModels,
);
summary = await Promise.race([
callProgressModel(this.config.progressModel, progressContext, this.config.customModels),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('Progress model timed out')),
PROGRESS_MODEL_TIMEOUT_MS,
),
),
]);
this.config.logWriter('INFO', 'Progress model generated summary', {
elapsedMinutes: Math.round(elapsedMinutes),
summaryLength: summary.length,
Expand All @@ -287,6 +297,9 @@ export class ProgressMonitor implements ProgressReporter {
this.config.logWriter('WARN', 'Progress model failed, falling back to template', {
error: String(err),
});
captureException(err instanceof Error ? err : new Error(String(err)), {
tags: { source: 'progress_model', agentType: this.config.agentType },
});
summary = formatStatusMessage(
this.currentIteration,
this.maxIterations,
Expand Down Expand Up @@ -318,6 +331,17 @@ export class ProgressMonitor implements ProgressReporter {
if (!provider) return;

if (this.progressCommentId) {
// If the PostComment gadget (subprocess) cleared the state file,
// the agent has posted its final comment to this ID — do not overwrite.
const stateFile = readProgressCommentId(this.config.repoDir);
if (!stateFile) {
this.config.logWriter('DEBUG', 'State file cleared by agent — skipping progress update', {
commentId: this.progressCommentId,
});
this.progressCommentId = null;
return;
}

// Subsequent ticks: update the existing comment.
// On success, the state file written by postInitialComment() remains
// valid (same comment ID), so no need to rewrite it here.
Expand Down
42 changes: 42 additions & 0 deletions src/jira/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,48 @@ export const jiraClient = {
}));
},

async searchProjects(): Promise<Array<{ key: string; name: string }>> {
logger.debug('Searching JIRA projects');
const result = await getClient().projects.searchProjects({ maxResults: 100 });
const values = (result.values ?? []) as Array<{ key?: string; name?: string }>;
return values.map((p) => ({
key: p.key ?? '',
name: p.name ?? '',
}));
},

async getProjectStatuses(projectKey: string): Promise<Array<{ name: string; id: string }>> {
logger.debug('Fetching JIRA project statuses', { projectKey });
const result = await getClient().projects.getAllStatuses({
projectIdOrKey: projectKey,
});
// getAllStatuses returns issueType-grouped statuses; flatten and deduplicate
const seen = new Set<string>();
const statuses: Array<{ name: string; id: string }> = [];
for (const issueType of result as Array<{
statuses?: Array<{ name?: string; id?: string }>;
}>) {
for (const status of issueType.statuses ?? []) {
const name = status.name ?? '';
if (name && !seen.has(name)) {
seen.add(name);
statuses.push({ name, id: status.id ?? '' });
}
}
}
return statuses;
},

async getFields(): Promise<Array<{ id: string; name: string; custom: boolean }>> {
logger.debug('Fetching JIRA fields');
const fields = await getClient().issueFields.getFields();
return (fields as Array<{ id?: string; name?: string; custom?: boolean }>).map((f) => ({
id: f.id ?? '',
name: f.name ?? '',
custom: f.custom ?? false,
}));
},

async createIssue(fields: Record<string, unknown>) {
logger.debug('Creating JIRA issue', {
project: (fields.project as { key?: string })?.key,
Expand Down
Loading