Skip to content
Open
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
19 changes: 19 additions & 0 deletions cli/src/commands/jira.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
StoredJiraOauthToken,
} from '../jira-oauth';
import { awaitOauthCallback, CALLBACK_URL } from '../oauth-callback-server';
import { checkRepoOnboarding, notOnboardedGuidance } from '../repo-onboarding';

/** Default label that triggers an ABCA task when applied to a Jira issue. */
const DEFAULT_LABEL_FILTER = 'bgagent';
Expand Down Expand Up @@ -652,6 +653,7 @@ export function makeJiraCommand(): Command {
.option('--label <label>', `Label that triggers a task (default: ${DEFAULT_LABEL_FILTER})`, DEFAULT_LABEL_FILTER)
.option('--region <region>', 'AWS region (defaults to configured region)')
.option('--stack-name <name>', 'CloudFormation stack name', 'backgroundagent-dev')
.option('--skip-onboarding-check', 'Persist the mapping even if the repo has no active Blueprint (the mapping cannot trigger until one is deployed)')
.action(async (cloudId: string, projectKey: string, opts) => {
const config = loadConfig();
const region = opts.region || config.region;
Expand All @@ -673,6 +675,23 @@ export function makeJiraCommand(): Command {
process.exit(1);
}

// Onboarding gate: refuse to persist a mapping for a repo that has
// no active Blueprint, since every label trigger would fail at
// task-creation with 422 REPO_NOT_ONBOARDED — and the failure is
// nearly invisible to the operator. An inconclusive check (no
// RepoTable output, IAM gap) warns and proceeds rather than blocks.
if (!opts.skipOnboardingCheck) {
const onboarding = await checkRepoOnboarding({ region, stackName: opts.stackName, repo: opts.repo });
if (onboarding.kind === 'not-onboarded') {
for (const line of notOnboardedGuidance(opts.repo, onboarding)) {
console.error(line);
}
process.exit(1);
} else if (onboarding.kind === 'unverifiable') {
console.error(`⚠ Could not verify repo onboarding (${onboarding.detail}); proceeding without the check.`);
}
}

const now = new Date().toISOString();
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region }));
await ddb.send(new PutCommand({
Expand Down
19 changes: 19 additions & 0 deletions cli/src/commands/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
StoredLinearOauthToken,
} from '../linear-oauth';
import { awaitOauthCallback, CALLBACK_URL } from '../oauth-callback-server';
import { checkRepoOnboarding, notOnboardedGuidance } from '../repo-onboarding';

/** Default label that triggers an ABCA task when applied to a Linear issue. */
const DEFAULT_LABEL_FILTER = 'bgagent';
Expand Down Expand Up @@ -1314,6 +1315,7 @@ export function makeLinearCommand(): Command {
.option('--team-id <id>', 'Optional Linear team UUID for the project (stored for debug)')
.option('--region <region>', 'AWS region (defaults to configured region)')
.option('--stack-name <name>', 'CloudFormation stack name', 'backgroundagent-dev')
.option('--skip-onboarding-check', 'Persist the mapping even if the repo has no active Blueprint (the mapping cannot trigger until one is deployed)')
.action(async (projectId: string, opts) => {
const config = loadConfig();
const region = opts.region || config.region;
Expand Down Expand Up @@ -1341,6 +1343,23 @@ export function makeLinearCommand(): Command {
process.exit(1);
}

// Onboarding gate: refuse to persist a mapping for a repo that has
// no active Blueprint, since every label trigger would fail at
// task-creation with 422 REPO_NOT_ONBOARDED (Linear shares the same
// create-task-core gate as Jira). An inconclusive check warns and
// proceeds rather than blocks.
if (!opts.skipOnboardingCheck) {
const onboarding = await checkRepoOnboarding({ region, stackName: opts.stackName, repo: opts.repo });
if (onboarding.kind === 'not-onboarded') {
for (const line of notOnboardedGuidance(opts.repo, onboarding)) {
console.error(line);
}
process.exit(1);
} else if (onboarding.kind === 'unverifiable') {
console.error(`⚠ Could not verify repo onboarding (${onboarding.detail}); proceeding without the check.`);
}
}

const now = new Date().toISOString();
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region }));
await ddb.send(new PutCommand({
Expand Down
150 changes: 150 additions & 0 deletions cli/src/repo-onboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* MIT No Attribution
*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/

import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';

/**
* Stack-output key that exposes the name of the RepoTable — the DynamoDB
* table the Blueprint construct writes a `status='active'` row into for
* every onboarded repository. Mirrors the `CfnOutput` id in
* `cdk/src/stacks/agent.ts`.
*/
export const REPO_TABLE_OUTPUT_KEY = 'RepoTableName';

/**
* Result of checking whether a repo has a deployed Blueprint (i.e. an
* active RepoTable row). Mirrors the runtime onboarding gate in
* `cdk/src/handlers/shared/repo-config.ts` (`lookupRepo`):
*
* - `onboarded` — an `active` row exists; the repo will trigger.
* - `not-onboarded` (`missing`) — no row at all for this `owner/repo`.
* - `not-onboarded` (`inactive`) — a row exists but `status != 'active'`
* (e.g. soft-removed); the runtime gate treats this as not onboarded.
* - `unverifiable` — the check could not run (RepoTable output absent, or
* an IAM / read error). The caller should warn and proceed rather than
* block on an inconclusive signal, since the misconfiguration is the
* check's, not the mapping's.
*/
export type RepoOnboardingResult =
| { readonly kind: 'onboarded' }
| { readonly kind: 'not-onboarded'; readonly reason: 'missing' | 'inactive'; readonly status?: string }
| { readonly kind: 'unverifiable'; readonly detail: string };

export interface CheckRepoOnboardingOptions {
readonly region: string;
readonly stackName: string;
/** The `owner/repo` string the operator is about to map. */
readonly repo: string;
}

/**
* Read a single stack output. Returns null when the stack or the output
* does not exist, so callers can distinguish "not deployed" from a real
* AWS error (which is rethrown).
*/
async function getStackOutput(region: string, stackName: string, outputKey: string): Promise<string | null> {
const cfn = new CloudFormationClient({ region });
try {
const result = await cfn.send(new DescribeStacksCommand({ StackName: stackName }));
const output = (result.Stacks?.[0]?.Outputs ?? []).find((o) => o.OutputKey === outputKey);
return output?.OutputValue ?? null;
} catch (err) {
const name = (err as Error)?.name ?? '';
const message = (err as Error)?.message ?? '';
if (name === 'ValidationError' && /does not exist/i.test(message)) {
return null;
}
throw err;
}
}

/**
* Check whether `opts.repo` is onboarded — i.e. has an `active` row in the
* deployed RepoTable, the same condition the task-submit gate enforces at
* trigger time (`422 REPO_NOT_ONBOARDED`). Run this at `map`/`onboard`
* time so an operator learns immediately that a mapping can never fire,
* rather than discovering it deep in the processor when a label is added.
*
* Never throws for the "not onboarded" case — that is a normal verdict the
* caller turns into actionable guidance. A genuinely inconclusive check
* (missing output, IAM gap) returns `unverifiable` with detail so the
* caller can warn-and-proceed instead of falsely blocking a valid mapping.
*/
export async function checkRepoOnboarding(opts: CheckRepoOnboardingOptions): Promise<RepoOnboardingResult> {
let repoTableName: string | null;
try {
repoTableName = await getStackOutput(opts.region, opts.stackName, REPO_TABLE_OUTPUT_KEY);
} catch (err) {
return { kind: 'unverifiable', detail: `could not read stack outputs: ${err instanceof Error ? err.message : String(err)}` };
}
if (!repoTableName) {
return {
kind: 'unverifiable',
detail: `stack '${opts.stackName}' has no ${REPO_TABLE_OUTPUT_KEY} output (deploy the latest CDK stack to enable the onboarding check)`,
};
}

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: opts.region }));
let item: Record<string, unknown> | undefined;
try {
const result = await ddb.send(new GetCommand({
TableName: repoTableName,
Key: { repo: opts.repo },
}));
item = result.Item;
} catch (err) {
return { kind: 'unverifiable', detail: `could not read RepoTable '${repoTableName}': ${err instanceof Error ? err.message : String(err)}` };
}

if (!item) {
return { kind: 'not-onboarded', reason: 'missing' };
}
const status = typeof item.status === 'string' ? item.status : undefined;
if (status !== 'active') {
return { kind: 'not-onboarded', reason: 'inactive', status };
}
return { kind: 'onboarded' };
}

/**
* Build the multi-line operator guidance printed when a repo is not
* onboarded. Shared by the Jira and Linear map commands so the remediation
* steps stay identical. `repo` is the offending `owner/repo`.
*/
export function notOnboardedGuidance(repo: string, result: Extract<RepoOnboardingResult, { kind: 'not-onboarded' }>): string[] {
const lead = result.reason === 'inactive'
? `Repository '${repo}' has a RepoTable row but its status is '${result.status ?? 'unknown'}' (not 'active'), so the task-submit gate will reject every trigger with 422 REPO_NOT_ONBOARDED.`
: `Repository '${repo}' is not onboarded — it has no active Blueprint, so the task-submit gate will reject every trigger with 422 REPO_NOT_ONBOARDED.`;
return [
lead,
'',
'Onboard the repo first by deploying a Blueprint for it, then re-run this command:',
'',
' 1. Add (or point) a Blueprint construct at this repo in cdk/src/stacks/agent.ts,',
' e.g. set BLUEPRINT_REPO / the `blueprintRepo` CDK context, or instantiate',
` new Blueprint(this, 'MyRepoBlueprint', { repo: '${repo}', repoTable: repoTable.table });`,
' 2. Deploy: MISE_EXPERIMENTAL=1 mise //cdk:deploy',
' 3. Re-run this map command.',
'',
'See docs/guides/QUICK_START.md (“Onboard a repository / Blueprint”) for the full steps.',
'To map anyway (e.g. you are deploying the Blueprint momentarily), pass --skip-onboarding-check.',
];
}
70 changes: 65 additions & 5 deletions cli/test/commands/jira.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
PutSecretValueCommand,
ResourceExistsException,
} from '@aws-sdk/client-secrets-manager';
import { GetCommand } from '@aws-sdk/lib-dynamodb';
import { ApiClient } from '../../src/api-client';
import {
isWebhookSecretConfigured,
Expand Down Expand Up @@ -365,9 +366,22 @@ describe('jira map action', () => {
let exitSpy: jest.SpiedFunction<typeof process.exit>;

beforeEach(() => {
ddbSend.mockReset().mockResolvedValue({});
// The `map` action issues a GetItem (onboarding check) then a PutItem
// (the mapping). Differentiate by command type: GetItem → an active
// RepoTable row (onboarded), PutItem → ack.
ddbSend.mockReset().mockImplementation((cmd: unknown) => {
if (cmd instanceof GetCommand) {
return Promise.resolve({ Item: { repo: 'owner/repo', status: 'active' } });
}
return Promise.resolve({});
});
cfnSend.mockReset().mockResolvedValue({
Stacks: [{ Outputs: [{ OutputKey: 'JiraProjectMappingTableName', OutputValue: 'ProjMapTable' }] }],
Stacks: [{
Outputs: [
{ OutputKey: 'JiraProjectMappingTableName', OutputValue: 'ProjMapTable' },
{ OutputKey: 'RepoTableName', OutputValue: 'RepoTable' },
],
}],
});
loadConfigSpy = jest.spyOn(config, 'loadConfig').mockReturnValue({ region: 'us-west-2' } as ReturnType<typeof config.loadConfig>);
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
Expand All @@ -390,10 +404,16 @@ describe('jira map action', () => {
await program.parseAsync(['node', 'bgagent', 'map', ...args]);
}

/** The PutCommand input from the mapping write (skips the GetItem check). */
function putMappingInput(): { TableName: string; Item: Record<string, unknown> } {
const call = ddbSend.mock.calls.find((c) => !(c[0] instanceof GetCommand));
if (!call) throw new Error('no PutCommand was issued');
return call[0].input;
}

test('writes an active mapping row with the resolved label on the happy path', async () => {
await runMap(['cloud-123', 'ENG', '--repo', 'owner/repo', '--label', 'agentme']);
expect(ddbSend).toHaveBeenCalledTimes(1);
const putInput = ddbSend.mock.calls[0][0].input;
const putInput = putMappingInput();
expect(putInput.TableName).toBe('ProjMapTable');
expect(putInput.Item).toMatchObject({
jira_project_identity: 'cloud-123#ENG',
Expand All @@ -407,7 +427,7 @@ describe('jira map action', () => {

test('defaults the trigger label to bgagent when --label is omitted', async () => {
await runMap(['cloud-123', 'ENG', '--repo', 'owner/repo']);
expect(ddbSend.mock.calls[0][0].input.Item.label_filter).toBe('bgagent');
expect(putMappingInput().Item.label_filter).toBe('bgagent');
});

test('rejects an invalid --repo value before writing', async () => {
Expand All @@ -425,6 +445,46 @@ describe('jira map action', () => {
await expect(runMap(['cloud-123', 'ENG', '--repo', 'owner/repo'])).rejects.toThrow('process.exit:1');
expect(ddbSend).not.toHaveBeenCalled();
});

test('rejects a repo with no RepoTable row (not onboarded) before writing the mapping', async () => {
ddbSend.mockImplementation((cmd: unknown) => {
if (cmd instanceof GetCommand) return Promise.resolve({}); // no Item
return Promise.resolve({});
});
await expect(runMap(['cloud-123', 'ENG', '--repo', 'owner/repo'])).rejects.toThrow('process.exit:1');
// GetItem ran; no PutItem.
expect(ddbSend).toHaveBeenCalledTimes(1);
expect(ddbSend.mock.calls[0][0]).toBeInstanceOf(GetCommand);
expect(consoleErrorSpy.mock.calls.flat().join('\n')).toContain('REPO_NOT_ONBOARDED');
});

test('rejects a repo whose RepoTable row is not active (soft-removed)', async () => {
ddbSend.mockImplementation((cmd: unknown) => {
if (cmd instanceof GetCommand) return Promise.resolve({ Item: { repo: 'owner/repo', status: 'removed' } });
return Promise.resolve({});
});
await expect(runMap(['cloud-123', 'ENG', '--repo', 'owner/repo'])).rejects.toThrow('process.exit:1');
expect(consoleErrorSpy.mock.calls.flat().join('\n')).toContain("status is 'removed'");
});

test('--skip-onboarding-check persists the mapping without a RepoTable read', async () => {
ddbSend.mockImplementation((cmd: unknown) => {
if (cmd instanceof GetCommand) throw new Error('onboarding check should be skipped');
return Promise.resolve({});
});
await runMap(['cloud-123', 'ENG', '--repo', 'owner/repo', '--skip-onboarding-check']);
expect(ddbSend.mock.calls.some((c) => c[0] instanceof GetCommand)).toBe(false);
expect(putMappingInput().Item).toMatchObject({ repo: 'owner/repo', status: 'active' });
});

test('warns and proceeds when onboarding cannot be verified (no RepoTable output)', async () => {
cfnSend.mockResolvedValue({
Stacks: [{ Outputs: [{ OutputKey: 'JiraProjectMappingTableName', OutputValue: 'ProjMapTable' }] }],
});
await runMap(['cloud-123', 'ENG', '--repo', 'owner/repo']);
expect(consoleErrorSpy.mock.calls.flat().join('\n')).toContain('Could not verify repo onboarding');
expect(putMappingInput().Item).toMatchObject({ repo: 'owner/repo' });
});
});

describe('renderJiraAppTemplate', () => {
Expand Down
Loading
Loading