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
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ function getCreateListingPRContext(
? input.branchName.trim()
: '';
let headBranch =
explicitBranchName || toBranchName(roomId, listingDisplayName);
explicitBranchName || toBranchName(listingDisplayName);

if (!headBranch) {
throw new Error('pr-listing-create trigger must include a valid branch');
Expand Down Expand Up @@ -256,10 +256,7 @@ export class CreateListingPRHandler {
}

function buildSubmissionFolderName(context: CreateListingPRContext): string {
// Wrap files under the room segment of the branch path
// ("room-<base64-roomId>"). Each PR is scoped to one branch, so the room
// segment alone uniquely identifies the submission's location on disk.
return context.head.split('/')[0];
return context.head;
Comment thread
richardhjtan marked this conversation as resolved.
Comment thread
richardhjtan marked this conversation as resolved.
}

function mapOpenPullRequestResult(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export class PrListingWorkflowHandler implements BotCommandHandler {
// and the persisted card attribute all use the exact same string.
let branchName =
optionalString(input.branchName) ??
toBranchName(roomId, listingName ?? 'UntitledListing');
toBranchName(listingName ?? 'UntitledListing');

return {
runAs,
Expand All @@ -157,7 +157,10 @@ export class PrListingWorkflowHandler implements BotCommandHandler {
workflowCardUrl,
workflowCardRealm,
branchName,
syntheticCreateEvent: eventContent,
syntheticCreateEvent: {
...eventContent,
input: { ...(input as Record<string, unknown>), branchName },
},
existingPrCardUrl: null,
};
}
Expand Down Expand Up @@ -203,7 +206,7 @@ export class PrListingWorkflowHandler implements BotCommandHandler {
stripSubmitPrefix(optionalString(attrs.title));
let branchName =
optionalString(attrs.branchName) ??
toBranchName(roomId, listingName ?? 'UntitledListing');
toBranchName(listingName ?? 'UntitledListing');
Comment thread
richardhjtan marked this conversation as resolved.
let rawPrCardSelf = optionalString(rels.prCard?.links?.self);
let existingPrCardUrl = rawPrCardSelf
? new URL(rawPrCardSelf, workflowCardUrl).href
Expand Down
16 changes: 8 additions & 8 deletions packages/bot-runner/tests/bot-runner-test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { module, test } from 'qunit';
import {
toBranchName,
type DBAdapter,
type ExecuteOptions,
type PgPrimitive,
type QueuePublisher,
import type {
DBAdapter,
ExecuteOptions,
PgPrimitive,
QueuePublisher,
} from '@cardstack/runtime-common';
import type {
MatrixClient,
Expand Down Expand Up @@ -305,6 +304,7 @@ module('timeline handler', () => {
roomId: '!abc123:localhost',
listingId: 'http://localhost:4201/test/Listing/1',
listingName: 'My Listing',
branchName: 'a1b2c3-my-listing',
},
realm: 'http://localhost:4201/test/',
userId: '@alice:localhost',
Expand Down Expand Up @@ -344,7 +344,7 @@ module('timeline handler', () => {
write.branch?.toString().includes('my-listing'),
'commit branch includes listing slug',
);
let folder = toBranchName('!abc123:localhost', 'My Listing').split('/')[0];
let folder = 'a1b2c3-my-listing';
assert.deepEqual(
write.files,
[
Expand All @@ -359,7 +359,7 @@ module('timeline handler', () => {
isBinary: false,
},
],
'commit payload wraps parsed files in room-<base64> folder',
'commit payload wraps parsed files in {hash}-{slug} folder',
);
assert.true(
write.message
Expand Down
7 changes: 4 additions & 3 deletions packages/bot-runner/tests/command-runner-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ module('command runner', () => {
listingName: 'My Listing Name',
listingSummary: 'My listing Summary',
workflowCardUrl: submissionCardUrl,
branchName: 'a1b2c3-my-listing-name',
},
},
'bot-registration-2',
Expand Down Expand Up @@ -321,7 +322,7 @@ module('command runner', () => {
command: '@cardstack/catalog/commands/create-pr-card/default',
commandInput: {
realm: SUBMISSION_REALM_URL,
branchName: 'room-IWFiYzEyMzpsb2NhbGhvc3Q/my-listing-name',
branchName: 'a1b2c3-my-listing-name',
submittedBy: '@alice:localhost',
prSummary: `## Summary\nMy listing Summary\n\n---\n- Listing Name: My Listing Name\n- Room ID: \`!abc123:localhost\`\n- User ID: \`@alice:localhost\`\n- Number of Files: 1\n- Workflow Card: [${submissionCardUrl}](${submissionCardUrl})`,
allFileContents: [
Expand Down Expand Up @@ -800,7 +801,7 @@ module('command runner', () => {
// retry must NOT derive branchName from it.
title: 'Submit My Listing',
branchName:
'room-IWFiYzEyMzpsb2NhbGhvc3Q/my-listing',
'a1b2c3-my-listing',
},
relationships: {
listing: {
Expand Down Expand Up @@ -950,7 +951,7 @@ module('command runner', () => {
// any previous commits/PR.
assert.strictEqual(
(createdBranches[0] as { branch: string }).branch,
'room-IWFiYzEyMzpsb2NhbGhvc3Q/my-listing',
'a1b2c3-my-listing',
'retry uses the persisted branchName from the workflow card',
);
});
Expand Down
79 changes: 40 additions & 39 deletions packages/bot-runner/tests/create-listing-pr-handler-test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { module, test } from 'qunit';
import { toBranchName } from '@cardstack/runtime-common';
import type { GitHubClient } from '../lib/github';
import {
CreateListingPRHandler,
type BotTriggerEventContent,
} from '../lib/pr-listing/create-listing-pr-handler';

function expectedFolderName(roomId: string, listingName: string): string {
return toBranchName(roomId, listingName).split('/')[0];
}
const BRANCH_PATTERN = /^[a-f0-9]{6}-/;

module('create-listing-pr handler', () => {
test('opens PR with expected params and summary body', async (assert) => {
Expand Down Expand Up @@ -61,8 +58,8 @@ module('create-listing-pr handler', () => {
'returns PR title',
);
assert.true(
result?.branchName?.endsWith('/my-listing') ?? false,
'returns branch name used to open the PR',
/^[a-f0-9]{6}-my-listing$/.test(result?.branchName ?? ''),
`returns branch name used to open the PR: ${result?.branchName}`,
);
assert.true(
result?.summary?.includes('## Summary') ?? false,
Expand Down Expand Up @@ -195,7 +192,7 @@ module('create-listing-pr handler', () => {
assert.strictEqual(result, null, 'returns null when PR already exists');
});

test('addContentsToCommit wraps files under the room-<base64> folder', async (assert) => {
test('addContentsToCommit wraps files under the {hash}-{slug} folder', async (assert) => {
let writeCalls: { files: { path: string; content: string }[]; message: string }[] = [];
let githubClient: GitHubClient = {
openPullRequest: async () => ({ number: 1, html_url: 'x' }),
Expand All @@ -207,13 +204,16 @@ module('create-listing-pr handler', () => {
},
};

let roomId = '!abc123:localhost';
let listingName = 'My Listing';
let branchName = 'a1b2c3-my-listing';
let eventContent: BotTriggerEventContent = {
type: 'pr-listing-create',
realm: 'http://localhost:4201/test/',
userId: '@alice:localhost',
input: { roomId, listingName },
input: {
roomId: '!abc123:localhost',
listingName: 'My Listing',
branchName,
},
};

let handler = new CreateListingPRHandler(githubClient);
Expand All @@ -233,23 +233,18 @@ module('create-listing-pr handler', () => {
});

assert.strictEqual(writeCalls.length, 1, 'writes once');
let folder = expectedFolderName(roomId, listingName);
assert.deepEqual(
writeCalls[0].files.map((f) => f.path).sort(),
[
`${folder}/CardListing/abc.json`,
`${folder}/Recipe.gts`,
`${folder}/Spec/def.json`,
`${branchName}/CardListing/abc.json`,
`${branchName}/Recipe.gts`,
`${branchName}/Spec/def.json`,
],
'every file is prefixed with the wrapper folder, inner type-grouped layout preserved',
);
assert.true(
folder.startsWith('room-'),
'folder uses the branch room segment as prefix',
'every file is prefixed with the branch-name folder, inner layout preserved',
);
});

test('addContentsToCommit produces the same folder for the same branch (idempotency)', async (assert) => {
test('addContentsToCommit produces the same folder when branchName is persisted (idempotency)', async (assert) => {
let writeCalls: { path: string }[][] = [];
let githubClient: GitHubClient = {
openPullRequest: async () => ({ number: 1, html_url: 'x' }),
Expand All @@ -265,7 +260,11 @@ module('create-listing-pr handler', () => {
type: 'pr-listing-create',
realm: 'http://localhost:4201/test/',
userId: '@alice:localhost',
input: { roomId: '!abc123:localhost', listingName: 'My Listing' },
input: {
roomId: '!abc123:localhost',
listingName: 'My Listing',
branchName: 'a1b2c3-my-listing',
},
};

let handler = new CreateListingPRHandler(githubClient);
Expand All @@ -286,11 +285,11 @@ module('create-listing-pr handler', () => {
assert.deepEqual(
writeCalls[0],
writeCalls[1],
'same branch + same files → same prefixed paths across runs',
'same persisted branchName → same prefixed paths across runs',
);
});

test('addContentsToCommit uses a different folder for a different room', async (assert) => {
test('addContentsToCommit folder matches the {hash}-{slug} pattern when branchName is not provided', async (assert) => {
let folders: string[] = [];
let githubClient: GitHubClient = {
openPullRequest: async () => ({ number: 1, html_url: 'x' }),
Expand All @@ -314,22 +313,24 @@ module('create-listing-pr handler', () => {
}),
};

for (let roomId of ['!room-a:localhost', '!room-b:localhost']) {
await handler.addContentsToCommit(
{
type: 'pr-listing-create',
realm: 'http://localhost:4201/test/',
userId: '@alice:localhost',
input: { roomId, listingName: 'My Listing' },
},
runResult,
);
}

assert.notStrictEqual(
folders[0],
folders[1],
'two different rooms with the same listing name produce distinct folders',
await handler.addContentsToCommit(
{
type: 'pr-listing-create',
realm: 'http://localhost:4201/test/',
userId: '@alice:localhost',
input: { roomId: '!room-a:localhost', listingName: 'My Listing' },
},
runResult,
);

assert.strictEqual(folders.length, 1, 'wrote once');
assert.ok(
BRANCH_PATTERN.test(folders[0]),
`folder ${folders[0]} matches {hash6}-{slug}`,
);
assert.ok(
folders[0].endsWith('-my-listing'),
'folder ends with kebab-cased listing slug',
);
});

Expand Down
6 changes: 3 additions & 3 deletions packages/host/app/commands/create-submission-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ export default class CreateSubmissionWorkflowCommand extends HostBaseCommand<
});
let roomId = createRoomResult.roomId;

// Branch name is derived from (roomId, listingName) and persisted so
// retry doesn't have to reconstruct it from the display title.
let branchName = toBranchName(roomId, listingName ?? 'UntitledListing');
// Branch name is generated once and persisted on the workflow card so
// retries reuse the same GitHub branch.
let branchName = toBranchName(listingName ?? 'UntitledListing');

// Cleanup window covers everything between "room exists" and "workflow
// card persisted with roomId baked in". Once the card exists, leaving
Expand Down
73 changes: 8 additions & 65 deletions packages/runtime-common/github-submissions.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,9 @@
const ROOM_ID_PREFIX = 'room-';
const HASH_BYTES = 3;

function toBase64Url(input: string): string {
let base64 =
typeof btoa === 'function'
? btoa(input)
: Buffer.from(input, 'utf8').toString('base64');
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}

function fromBase64Url(input: string): string {
let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
let padding = base64.length % 4;
if (padding !== 0) {
base64 += '='.repeat(4 - padding);
}
return typeof atob === 'function'
? atob(base64)
: Buffer.from(base64, 'base64').toString('utf8');
}

function matrixRoomIdToBranchName(matrixRoomId: string): string {
if (!matrixRoomId) {
throw new Error('matrixRoomId is required');
}
return `${ROOM_ID_PREFIX}${toBase64Url(matrixRoomId)}`;
function generateHash(): string {
let bytes = new Uint8Array(HASH_BYTES);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
Comment thread
richardhjtan marked this conversation as resolved.
Comment thread
richardhjtan marked this conversation as resolved.
}

function toKebabSlug(value: string): string {
Expand All @@ -35,48 +15,11 @@ function toKebabSlug(value: string): string {
.replace(/^-+|-+$/g, '');
}

export function toBranchName(
matrixRoomId: string,
listingName: string,
): string {
export function toBranchName(listingName: string): string {
if (!listingName) {
throw new Error('listingName is required');
}
let roomPrefix = matrixRoomIdToBranchName(matrixRoomId);
let listingSlug = toKebabSlug(listingName);
return listingSlug ? `${roomPrefix}/${listingSlug}` : roomPrefix;
}

export interface SubmissionMeta {
matrixRoomId: string;
listingName?: string;
}

export function branchNameToSubmissionMeta(branchName: string): SubmissionMeta {
let [roomSegment, ...listingParts] = branchName.split('/');
let matrixRoomId = decodeMatrixRoomIdFromBranchSegment(roomSegment);
let listingName = listingParts.join('/');
return listingName ? { matrixRoomId, listingName } : { matrixRoomId };
}

function decodeMatrixRoomIdFromBranchSegment(branchName: string): string {
if (!branchName) {
throw new Error('branchName is required');
}
let roomSegment = branchName.split('/')[0];
if (!roomSegment.startsWith(ROOM_ID_PREFIX)) {
throw new Error('branchName does not include a matrix room id prefix');
}
let encoded = roomSegment.slice(ROOM_ID_PREFIX.length);
if (!encoded) {
throw new Error('branchName has no encoded matrix room id');
}
if (!/^[A-Za-z0-9_-]+$/.test(encoded)) {
throw new Error('branchName has an invalid encoded matrix room id');
}
try {
return fromBase64Url(encoded);
} catch {
throw new Error('branchName has an invalid encoded matrix room id');
}
let hash = generateHash();
return listingSlug ? `${hash}-${listingSlug}` : hash;
Comment thread
richardhjtan marked this conversation as resolved.
}
Loading
Loading