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
124 changes: 85 additions & 39 deletions src/api/routers/pm-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,65 +15,111 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { getIntegrationCredentialOrNull } from '../../config/provider.js';
import { getIntegrationByProjectAndCategory } from '../../db/repositories/integrationsRepository.js';
import type { PMProviderManifest } from '../../integrations/pm/manifest.js';
import { getPMProvider, listPMProviders } from '../../integrations/pm/registry.js';
import { DISCOVERY_CAPABILITIES } from '../../pm/types.js';
import { protectedProcedure, router } from '../trpc.js';
import { verifyProjectOrgAccess } from './_shared/projectAccess.js';

/**
* Invoke a manifest's optional `configToCredentials` hook and return the
* promoted bag. Guards against malformed hook returns and swallows hook
* errors with a warn so one broken provider cannot take down discovery
* for everyone.
*/
function promoteConfigCredentials(
manifest: PMProviderManifest,
integrationConfig: unknown,
): Record<string, string> {
if (!manifest.configToCredentials) return {};
try {
const promoted = manifest.configToCredentials(integrationConfig);
return promoted && typeof promoted === 'object' ? promoted : {};
} catch (err) {
console.warn(`[pm-discovery] configToCredentials threw for provider '${manifest.id}':`, err);
return {};
}
}

/**
* Load + validate the PM integration for a given project. Throws the
* appropriate tRPC error when missing, misconfigured, or when the manifest
* has been deregistered.
*/
async function loadIntegrationAndManifest(
projectId: string,
providerId: string,
): Promise<{ integration: { config: unknown }; manifest: PMProviderManifest }> {
const integration = await getIntegrationByProjectAndCategory(projectId, 'pm');
if (!integration) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No PM integration configured for this project yet',
});
}
if (integration.provider !== providerId) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Project is configured with a different PM provider (${integration.provider})`,
});
}
const manifest = getPMProvider(providerId);
if (!manifest) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Unknown PM provider '${providerId}'`,
});
}
return { integration, manifest };
}

/**
* Shared credential resolver for pm.discovery.* endpoints. Accepts either
* `credentials` directly or a `projectId` — if `projectId` is set, the
* caller must have org access to the project, and we resolve each declared
* credential role from the project_credentials table.
*
* On the projectId path, the manifest's optional `configToCredentials` hook
* seeds the bag with non-secret connection fields promoted from
* `project_integrations.config` (e.g. JIRA's cloud tenant `baseUrl`).
* Values written from `project_credentials` override any key collisions —
* the DB-scoped secret always wins over config-derived defaults.
*
* Returns a `Record<string, string>` shaped by the manifest's
* `credentialRoles` — the shape downstream hooks / `createDiscoveryProvider`
* factories consume.
* `credentialRoles` (plus any promoted-config fields) — the shape
* downstream hooks / `createDiscoveryProvider` factories consume.
*/
async function resolvePMCredentials(opts: {
providerId: string;
effectiveOrgId: string | null;
credentials?: Record<string, string>;
projectId?: string;
}): Promise<Record<string, string>> {
if (opts.projectId) {
if (!opts.effectiveOrgId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
await verifyProjectOrgAccess(opts.projectId, opts.effectiveOrgId);
const integration = await getIntegrationByProjectAndCategory(opts.projectId, 'pm');
if (!integration) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No PM integration configured for this project yet',
});
}
if (integration.provider !== opts.providerId) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Project is configured with a different PM provider (${integration.provider})`,
});
}
const manifest = getPMProvider(opts.providerId);
if (!manifest) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Unknown PM provider '${opts.providerId}'`,
});
}
const resolved: Record<string, string> = {};
for (const role of manifest.credentialRoles) {
const value = await getIntegrationCredentialOrNull(
opts.projectId,
'pm',
opts.providerId,
role.role,
);
if (value) resolved[role.role] = value;
}
return resolved;
if (!opts.projectId) return opts.credentials ?? {};

if (!opts.effectiveOrgId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
await verifyProjectOrgAccess(opts.projectId, opts.effectiveOrgId);

const { integration, manifest } = await loadIntegrationAndManifest(
opts.projectId,
opts.providerId,
);

const resolved: Record<string, string> = {
...promoteConfigCredentials(manifest, integration.config),
};
for (const role of manifest.credentialRoles) {
const value = await getIntegrationCredentialOrNull(
opts.projectId,
'pm',
opts.providerId,
role.role,
);
if (value) resolved[role.role] = value;
}
return opts.credentials ?? {};
return resolved;
}

const providerIdInput = z.object({
Expand Down
27 changes: 10 additions & 17 deletions src/gadgets/github/core/createPR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@ export interface CreatePRResult {
commitOutput?: string;
}

// Spec 013: per-caller timeouts for the two commands that trigger user-defined
// hooks. Values are sized to sit just under the gadget's 240s ceiling and to
// give test suites enough headroom for their slowest inter-event gaps.
const PUSH_WALL_TIMEOUT_MS = 230_000;
const PUSH_IDLE_TIMEOUT_MS = 90_000;
const COMMIT_WALL_TIMEOUT_MS = 120_000;
const COMMIT_IDLE_TIMEOUT_MS = 60_000;
// Timeouts are deliberately disabled on `git commit` and `git push`. Both
// commands invoke user-defined hooks (lefthook, husky, etc.) that can legitimately
// run full test suites for five-plus minutes. The agent harness that wraps this
// gadget handles long-running tool calls on its own, so a second shorter cap
// here would just re-introduce the "PUSH FAILED at 2 min" incident of spec 013.
// Heartbeat stays default (30s stderr pulse via runCommand), so operators still
// see `[git-push] still running (Ns)` ticks during slow hooks. Setting
// wallTimeoutMs + idleTimeoutMs to 0 disables them — see runCommand in utils/repo.ts.

async function detectOwnerRepo(): Promise<{ owner: string; repo: string }> {
const result = await runCommand('git', ['remote', 'get-url', 'origin'], process.cwd());
Expand Down Expand Up @@ -80,11 +81,7 @@ async function stageAndCommit(commitMessage: string): Promise<string> {
['commit', '-m', commitMessage],
process.cwd(),
undefined,
{
label: 'git-commit',
wallTimeoutMs: COMMIT_WALL_TIMEOUT_MS,
idleTimeoutMs: COMMIT_IDLE_TIMEOUT_MS,
},
{ label: 'git-commit', wallTimeoutMs: 0, idleTimeoutMs: 0 },
);
if (commitResult.exitCode !== 0) {
const output = [commitResult.stdout, commitResult.stderr].filter(Boolean).join('\n').trim();
Expand All @@ -105,11 +102,7 @@ async function pushBranch(branch: string): Promise<string> {
['push', '-u', 'origin', branch],
process.cwd(),
undefined,
{
label: 'git-push',
wallTimeoutMs: PUSH_WALL_TIMEOUT_MS,
idleTimeoutMs: PUSH_IDLE_TIMEOUT_MS,
},
{ label: 'git-push', wallTimeoutMs: 0, idleTimeoutMs: 0 },
);
if (pushResult.exitCode !== 0) {
const output = [pushResult.stdout, pushResult.stderr].filter(Boolean).join('\n').trim();
Expand Down
10 changes: 8 additions & 2 deletions src/gadgets/github/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,14 @@ The PR body supports full GitHub-flavored markdown including:
- Tables

NOTE: Pre-commit and pre-push hooks may run tests which can take time.
If hooks fail or timeout, the full output will be shown.`,
timeoutMs: 240000, // 4 minutes - hooks may run test suites
If hooks fail, the full output will be shown.`,
// Disabled: pre-commit / pre-push hooks can legitimately run a full test
// suite for 5+ minutes. The agent harness handles long-running tool calls
// on its own; `timeoutMs: 0` tells llmist not to arm an outer timer (see
// `if (timeoutMs && timeoutMs > 0)` in the dispatch path). runCommand's
// subprocess timeouts for `git commit` / `git push` are likewise disabled
// in core/createPR.ts.
timeoutMs: 0,
parameters: {
comment: {
type: 'string',
Expand Down
1 change: 1 addition & 0 deletions src/integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ See [`src/integrations/pm/manifest.ts`](./pm/manifest.ts) for the authoritative
| `isSelfAuthoredHook?` | Optional — returns `true` when the event was authored by CASCADE itself (for loop prevention). |
| `createLabel?` | Optional — enables the wizard's "Create label" button. Called via the generic `pm.discovery.createLabel` tRPC endpoint; signature is `({credentials, containerId, name, color?}) => {id, name, color}`. |
| `createCustomField?` | Optional — enables wizard-driven custom-field creation. Called via `pm.discovery.createCustomField`; signature is `({credentials, containerId, name}) => {id, name, type}`. JIRA fields are global (the hook ignores containerId). |
| `configToCredentials?` | Optional — promotes non-secret connection fields from `project_integrations.config` into the credentials bag `createDiscoveryProvider` consumes. Signature: `(config: unknown) => Record<string, string>`. Invoked only on the `projectId` path of `pm.discovery.*`; `project_credentials` values win on key collisions. Declare this when your provider stores tenant/host info in config instead of credentials (JIRA's `baseUrl` → `base_url`). Without it, edit-mode wizard re-verification constructs a client with empty host info — see prod incident 2026-04-24. |

### Plan 009 hardened-contract fields (all optional; providers opt in)

Expand Down
16 changes: 16 additions & 0 deletions src/integrations/pm/jira/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,22 @@ export const jiraManifest: PMProviderManifest = {
],
},

/**
* JIRA's cloud tenant URL is a non-secret connection field stored on
* `project_integrations.config.baseUrl`, not `project_credentials`. The
* pm-discovery resolver invokes this hook on the projectId path to
* promote the URL into the credentials bag the `createDiscoveryProvider`
* factory below consumes. Without it, edit-mode re-verification in the
* wizard constructs `new Version3Client({ host: '' })` and throws
* "Couldn't parse the host URL" (prod incident 2026-04-24).
*/
configToCredentials: (config: unknown): Record<string, string> => {
if (!config || typeof config !== 'object') return {};
const baseUrl = (config as { baseUrl?: unknown }).baseUrl;
if (typeof baseUrl !== 'string' || baseUrl.length === 0) return {};
return { base_url: baseUrl };
},

configSchema: jiraConfigSchema,
configFixture: {
projectKey: 'CASCADE',
Expand Down
22 changes: 22 additions & 0 deletions src/integrations/pm/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,28 @@ export interface PMProviderManifest {
readonly createDiscoveryProvider?: (opts?: {
credentials?: Record<string, string>;
}) => import('../../pm/types.js').PMProvider;

/**
* Promote fields from the persisted integration config into the credentials
* bag that `createDiscoveryProvider` consumes.
*
* Motivation: some providers (JIRA) require non-secret connection fields —
* like the cloud tenant URL — that belong on `project_integrations.config`,
* not in `project_credentials`. Without this hook, `pm.discovery.discover`
* resolving credentials by projectId produces a bag missing those fields,
* and the discovery adapter constructs a client with an empty host (see
* prod incident 2026-04-24: "Couldn't parse the host URL" in the JIRA
* wizard's Select Project step).
*
* Contract:
* - Invoked only on the projectId path of `resolvePMCredentials`. The
* explicit-credentials path (wizard first-time setup, with the user's
* raw form values) does not invoke it.
* - Values loaded from `project_credentials` take precedence on key
* collisions — hook-returned values fill gaps, they don't override.
* - Return `{}` (or undefined) when the config has nothing to promote.
*/
readonly configToCredentials?: (config: unknown) => Record<string, string>;
}

/**
Expand Down
Loading
Loading