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
10 changes: 10 additions & 0 deletions openswarm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"projectName": "OpenSwarm",
"linear": {
"teamId": "49b7af95-3cac-4a56-adc7-f19d77dfbe9b",
"teamKey": "INT",
"projectId": "74a9d092-7b3c-4d4d-a998-a2c9a8f08e83",
"projectName": "OpenSwarm"
}
}
59 changes: 59 additions & 0 deletions src/cli/linearMapping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

const getProfileMock = vi.fn();
vi.mock('../auth/index.js', () => ({
AuthProfileStore: vi.fn().mockImplementation(function AuthProfileStore(this: unknown) {
return { getProfile: getProfileMock };
}),
ensureValidToken: vi.fn(),
}));

const loadConfigMock = vi.fn();
vi.mock('../core/config.js', () => ({
loadConfig: (...args: unknown[]) => loadConfigMock(...args),
}));

const { resolveLinearCredential } = await import('./linearMapping.js');

describe('resolveLinearCredential (INT-2619)', () => {
const originalEnv = process.env.LINEAR_API_KEY;

beforeEach(() => {
vi.clearAllMocks();
getProfileMock.mockReturnValue(undefined);
loadConfigMock.mockReturnValue({ linearApiKey: '' });
delete process.env.LINEAR_API_KEY;
});

afterEach(() => {
if (originalEnv === undefined) delete process.env.LINEAR_API_KEY;
else process.env.LINEAR_API_KEY = originalEnv;
});

it('returns null when no OAuth profile, env var, or config apiKey is present', async () => {
expect(await resolveLinearCredential()).toBeNull();
});

it('falls back to LINEAR_API_KEY when no OAuth profile is stored', async () => {
process.env.LINEAR_API_KEY = 'env-key';
expect(await resolveLinearCredential()).toEqual({ apiKey: 'env-key' });
});

it('falls back to config.yaml `linear.apiKey` when neither OAuth profile nor env var is present (INT-2619)', async () => {
// This is the exact gap the reviewer caught: ensureTaskSource() initializes
// Linear from config.linearApiKey too, so the mapping preflight must check
// the same source or it wrongly concludes "Linear isn't configured" and lets
// filing proceed without a project.
loadConfigMock.mockReturnValue({ linearApiKey: 'config-key' });
expect(await resolveLinearCredential()).toEqual({ apiKey: 'config-key' });
});

it('prefers the OAuth profile over env var and config apiKey', async () => {
getProfileMock.mockReturnValue({ provider: 'linear' });
process.env.LINEAR_API_KEY = 'env-key';
loadConfigMock.mockReturnValue({ linearApiKey: 'config-key' });
const { ensureValidToken } = await import('../auth/index.js');
vi.mocked(ensureValidToken).mockResolvedValue('oauth-token');
expect(await resolveLinearCredential()).toEqual({ accessToken: 'oauth-token' });
});
});
13 changes: 11 additions & 2 deletions src/cli/linearMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import { AuthProfileStore, ensureValidToken } from '../auth/index.js';
/**
* Resolve a Linear credential for non-interactive use (no auth prompt):
* 1. linear:default OAuth profile (refreshed via ensureValidToken), then
* 2. LINEAR_API_KEY env var.
* Returns null when neither is present — the caller should hint at `auth login`.
* 2. LINEAR_API_KEY env var, then
* 3. config.yaml `linear.apiKey` — the same source ensureTaskSource() uses, so
* this stays in sync with what actually initializes Linear for filing. (INT-2619)
* Returns null when none is present — the caller should hint at `auth login`.
*/
export async function resolveLinearCredential(): Promise<LinearCredential | null> {
try {
Expand All @@ -31,6 +33,13 @@ export async function resolveLinearCredential(): Promise<LinearCredential | null
}
const apiKey = process.env.LINEAR_API_KEY?.trim();
if (apiKey) return { apiKey };
try {
const { loadConfig } = await import('../core/config.js');
const configApiKey = loadConfig().linearApiKey?.trim();
if (configApiKey) return { apiKey: configApiKey };
} catch { // cxt-ignore: error_swallow,exception_hiding — no usable config → no credential
/* fall through to null */
}
return null;
}

Expand Down
77 changes: 76 additions & 1 deletion src/cli/reviewCommand.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, it, expect, vi } from 'vitest';
import { buildReviewWorkerResult, formatReviewOutput, runReviewCommand, resolveIssueFromBranch } from './reviewCommand.js';
import {
buildReviewWorkerResult,
formatReviewOutput,
runReviewCommand,
resolveIssueFromBranch,
ensureProjectMapping,
} from './reviewCommand.js';
import type { ReviewResult } from '../agents/agentPair.js';

// Only exercised by tests that do NOT override deps.getChangedFiles — every
Expand Down Expand Up @@ -53,6 +59,73 @@ describe('resolveIssueFromBranch (INT-1967)', () => {
});
});

describe('ensureProjectMapping (INT-2599)', () => {
// A path with no openswarm.json, so resolveProjectId(cwd) always misses and
// each test's own stubs drive the rest of the decision tree.
const unmappedCwd = '/tmp/openswarm-test-ensure-project-mapping-unmapped';

it('short-circuits without touching Linear when an explicit parent is given', async () => {
const resolveCredential = vi.fn(async () => ({ apiKey: 'x' }));
const result = await ensureProjectMapping(unmappedCwd, 'INT-1', { resolveCredential });
expect(result).toEqual({ projectId: undefined, abort: false });
expect(resolveCredential).not.toHaveBeenCalled();
});

it('proceeds without a project when Linear is not configured at all', async () => {
const result = await ensureProjectMapping(unmappedCwd, undefined, {
resolveCredential: async () => null,
});
expect(result).toEqual({ projectId: undefined, abort: false });
});

it('fails closed (no orphan issue) when unmapped and there is no terminal to prompt', async () => {
const logs: string[] = [];
const pickAndSave = vi.fn();
const result = await ensureProjectMapping(unmappedCwd, undefined, {
resolveCredential: async () => ({ apiKey: 'x' }),
isTTY: false,
pickAndSave,
log: (l) => logs.push(l),
});
expect(result).toEqual({ projectId: undefined, abort: true });
expect(pickAndSave).not.toHaveBeenCalled();
expect(logs.join('\n')).toMatch(/openswarm add/);
});

it('maps interactively on a TTY and returns the saved project id', async () => {
const pickAndSave = vi.fn(async () => ({
kind: 'saved' as const,
teamId: 'team-1',
mapping: { teamId: 'team-1', projectId: 'proj-1', projectName: 'Demo' },
}));
const result = await ensureProjectMapping(unmappedCwd, undefined, {
resolveCredential: async () => ({ apiKey: 'x' }),
isTTY: true,
pickAndSave,
});
expect(result).toEqual({ projectId: 'proj-1', abort: false });
expect(pickAndSave).toHaveBeenCalledWith(unmappedCwd, { apiKey: 'x' });
});

it('fails closed when the Linear team lookup itself fails (no-teams is not a user choice) (INT-2619)', async () => {
const result = await ensureProjectMapping(unmappedCwd, undefined, {
resolveCredential: async () => ({ apiKey: 'x' }),
isTTY: true,
pickAndSave: async () => ({ kind: 'no-teams' as const }),
});
expect(result).toEqual({ projectId: undefined, abort: true });
});

it('proceeds without a project when the user actively skips the interactive picker', async () => {
const result = await ensureProjectMapping(unmappedCwd, undefined, {
resolveCredential: async () => ({ apiKey: 'x' }),
isTTY: true,
pickAndSave: async () => ({ kind: 'skipped' as const }),
});
expect(result).toEqual({ projectId: undefined, abort: false });
});
});

describe('runReviewCommand --issues branch inference (INT-1967)', () => {
const approveWithFollowups = async () =>
({ decision: 'approve', feedback: 'ok', recommendedActions: [{ type: 'test', title: 't' }] }) as ReviewResult;
Expand Down Expand Up @@ -84,6 +157,7 @@ describe('runReviewCommand --issues branch inference (INT-1967)', () => {
review: approveWithFollowups,
getBranch: async () => 'main',
fileFollowups: async () => 0, // e.g. Linear not configured
ensureProjectMapping: async () => ({ projectId: undefined, abort: false }),
startProgress: () => null,
log: (l) => logs.push(l),
},
Expand Down Expand Up @@ -112,6 +186,7 @@ describe('runReviewCommand --issues branch inference (INT-1967)', () => {
review: approveWithFollowups,
getBranch: async () => 'main',
fileFollowups,
ensureProjectMapping: async () => ({ projectId: undefined, abort: false }),
startProgress: () => null,
log: (l) => logs.push(l),
},
Expand Down
115 changes: 94 additions & 21 deletions src/cli/reviewCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,71 @@ export async function resolveProjectId(cwd: string): Promise<string | undefined>
}
}

export interface ProjectMappingResult {
/** Resolved Linear project id — undefined when none applies (explicit parent) or none is available. */
projectId: string | undefined;
/** True when Linear filing should be skipped entirely rather than create a project-less (orphan) issue. */
abort: boolean;
}

/**
* Preflight before filing a standalone/master Linear issue (no explicit
* `--issues <id>` parent to inherit a project from): if this repo has no
* openswarm.json Linear mapping yet, map it now — interactively on a TTY
* (writes openswarm.json, same picker as `openswarm add`), or fail closed
* headless so an unattended run never silently creates an orphan issue. (INT-2599)
*/
export async function ensureProjectMapping(
cwd: string,
parentIssueId: string | undefined,
deps: {
resolveCredential?: () => Promise<import('../linear/index.js').LinearCredential | null>;
pickAndSave?: (
repoPath: string,
cred: import('../linear/index.js').LinearCredential,
) => Promise<import('./linearMapping.js').MappingPickResult>;
isTTY?: boolean;
log?: (line: string) => void;
} = {},
): Promise<ProjectMappingResult> {
// A sub-issue under an explicit parent inherits that parent's project.
if (parentIssueId) return { projectId: undefined, abort: false };

const existing = await resolveProjectId(cwd);
if (existing) return { projectId: existing, abort: false };

const log = deps.log ?? ((l: string) => console.log(l));
const resolveCredential =
deps.resolveCredential ?? (async () => (await import('./linearMapping.js')).resolveLinearCredential());
const cred = await resolveCredential();
if (!cred) return { projectId: undefined, abort: false }; // Linear isn't configured — downstream reports that.

const isTTY = deps.isTTY ?? !!process.stdin.isTTY;
if (!isTTY) {
log(
`No Linear project mapped for this repo (openswarm.json missing) and no terminal to map it interactively — ` +
`skipping issue filing to avoid an unassigned (orphan) issue. Run \`openswarm add ${cwd}\` once in a ` +
`terminal to map it, then re-run.`,
);
return { projectId: undefined, abort: true };
}

log('This repo is not mapped to a Linear project yet — mapping it now:');
const pickAndSave =
deps.pickAndSave ?? (async (p, c) => (await import('./linearMapping.js')).pickAndSaveLinearMapping(p, c));
try {
const result = await pickAndSave(cwd, cred);
if (result.kind === 'saved') return { projectId: result.mapping.projectId, abort: false };
// 'no-teams' means the Linear lookup itself failed or returned nothing — not a
// user choice — so fail closed rather than silently file a project-less issue.
if (result.kind === 'no-teams') return { projectId: undefined, abort: true };
return { projectId: undefined, abort: false }; // user actively skipped the picker — not a silent gap.
} catch (err) {
if (err instanceof Error && err.name === 'ExitPromptError') return { projectId: undefined, abort: true };
throw err;
}
}

/**
* A Linear-backed task source for the standalone `review` CLI. The daemon
* registers one at startup, but a bare `openswarm review` does not — so init
Expand Down Expand Up @@ -151,6 +216,8 @@ export async function runReviewCommand(
getChangedFiles?: (cwd: string) => Promise<string[]>;
review?: (wr: WorkerResult, cwd: string, onLog?: (line: string) => void) => Promise<ReviewResult>;
fileFollowups?: (parentIssueId: string | undefined, review: ReviewResult) => Promise<number>;
/** Override the project-mapping preflight (tests stub this to skip the interactive picker). */
ensureProjectMapping?: (cwd: string, parentIssueId: string | undefined) => Promise<ProjectMappingResult>;
log?: (line: string) => void;
/** Override the progress indicator (default: TTY-gated spinner). Tests pass a stub. */
startProgress?: () => { note: (line: string) => void; stop: () => void } | null;
Expand Down Expand Up @@ -223,28 +290,34 @@ export async function runReviewCommand(
if (parent) log(`Filing follow-ups under ${parent} (inferred from branch "${branch}").`);
}
// No parent → create top-level (standalone) issues rather than refusing. (INT-1968)
// Default path initializes a Linear task source itself (the daemon isn't
// running here), and files regardless of decision. (INT-1969)
const fileFollowups =
deps.fileFollowups ??
(async (p: string | undefined, r: ReviewResult) => {
const { fileReviewerFollowups } = await import('../automation/runnerExecution.js');
const source = await ensureTaskSource();
if (!source) return 0;
const projectId = p ? undefined : await resolveProjectId(cwd);
return fileReviewerFollowups(source, p, r, { autoFile: true, projectId, requireApprove: false });
});
const filed = await fileFollowups(parent, result);
if (filed > 0) {
log(
parent
? `Filed ${filed} follow-up sub-issue(s) under ${parent}.`
: `Filed ${filed} standalone follow-up issue(s) (pass \`--issues <id>\` to nest them under an issue).`,
);
// Before that, make sure this repo actually has a Linear project to land
// in — map it now (interactive) or bail rather than file an orphan. (INT-2599)
const mapping = await (deps.ensureProjectMapping ?? (async (c, p) => ensureProjectMapping(c, p, { log })))(cwd, parent);
if (mapping.abort) {
log('Skipped filing follow-ups — map this repo to a Linear project first (see above), then re-run.');
} else {
log(
`Could not file follow-ups (0 created). Is Linear connected? Run \`openswarm auth login --provider linear\` (or set linearApiKey in config).`,
);
// Default path initializes a Linear task source itself (the daemon isn't
// running here), and files regardless of decision. (INT-1969)
const fileFollowups =
deps.fileFollowups ??
(async (p: string | undefined, r: ReviewResult) => {
const { fileReviewerFollowups } = await import('../automation/runnerExecution.js');
const source = await ensureTaskSource();
if (!source) return 0;
return fileReviewerFollowups(source, p, r, { autoFile: true, projectId: mapping.projectId, requireApprove: false });
});
const filed = await fileFollowups(parent, result);
if (filed > 0) {
log(
parent
? `Filed ${filed} follow-up sub-issue(s) under ${parent}.`
: `Filed ${filed} standalone follow-up issue(s) (pass \`--issues <id>\` to nest them under an issue).`,
);
} else {
log(
`Could not file follow-ups (0 created). Is Linear connected? Run \`openswarm auth login --provider linear\` (or set linearApiKey in config).`,
);
}
}
} else if (followups) {
// Suggestions were made but nothing was filed — make the flag discoverable. (INT-1966/1967)
Expand Down
Loading
Loading