Skip to content
Draft
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
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ The following are important concepts:
## Operations

- From-scratch indexing timeout: set `FROM_SCRATCH_JOB_TIMEOUT_SEC` (seconds) to control the from-scratch indexing job timeout and the queue worker cap; default is 2400.
- [Submissions Flow](submissions.md): Short command-level flow for Matrix event -> PR creation -> submission card creation.
12 changes: 12 additions & 0 deletions docs/submissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Submissions Flow

Submission flow is intentionally simple:

1. Host issues a Matrix bot-trigger event via `@cardstack/boxel-host/commands/create-listing-pr-request`, which calls `@cardstack/boxel-host/commands/send-bot-trigger-event` with `type: 'pr-listing-create'`.
2. `bot-runner` handles `pr-listing-create` and opens the GitHub PR in `packages/bot-runner/lib/create-listing-pr-handler.ts` (`openCreateListingPR`).
3. `bot-runner` runs `@cardstack/boxel-host/commands/create-submission` to create a `SubmissionCard` in the submissions realm.


```
http://localhost:4200/command-runner/%40cardstack%2Fboxel-host%2Fcommands%2Fcreate-submission%2Fdefault/%7B%22realm%22%3A%22http%3A%2F%2Flocalhost%3A4201%2Fexperiments%2F%22%2C%22roomId%22%3A%22!JTWMmANZcCwUHMIyaD%3Alocalhost%22%2C%22listingId%22%3A%22http%3A%2F%2Flocalhost%3A4201%2Fcatalog%2FAppListing%2F95cbe2c7-9b60-4afd-8a3c-1382b610e316%22%2C%22listingName%22%3A%22Blog%20App%22%7D/1
```
12 changes: 12 additions & 0 deletions packages/base/command.gts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,18 @@ export class CreateListingPRResult extends CardDef {
@field prNumber = contains(NumberField);
}

export class CreateSubmissionInput extends CardDef {
@field roomId = contains(StringField);
@field realm = contains(RealmField);
@field listingId = contains(StringField);
}

export class CreateSubmissionResult extends CardDef {
@field listing = linksTo(CardDef);
@field submission = linksTo(CardDef);
@field filesWithContent = containsMany(JsonField);
}

export class CreateListingPRRequestInput extends CardDef {
@field roomId = contains(StringField);
@field realm = contains(RealmField);
Expand Down
1 change: 1 addition & 0 deletions packages/base/matrix-event.gts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ export interface BotTriggerContent {
type: string;
realm: string;
input: unknown;
userId: string;
}

export interface BotTriggerEvent extends BaseMatrixEvent {
Expand Down
1 change: 1 addition & 0 deletions packages/bot-runner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Environment variables:
- `MATRIX_URL` (default: `http://localhost:8008`)
- `SUBMISSION_BOT_USERNAME` (default: `submissionbot`)
- `SUBMISSION_BOT_PASSWORD` (default: `password`)
- `SUBMISSION_BOT_GITHUB_TOKEN` (required for `pr-listing-create` workaround flow that opens PRs from bot-runner)
- `LOG_LEVELS` (default: `*=info`)
- `SENTRY_DSN` (optional)
- `SENTRY_ENVIRONMENT` (optional, default: `development`)
Expand Down
282 changes: 282 additions & 0 deletions packages/bot-runner/lib/create-listing-pr-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import { logger, toBranchName } from '@cardstack/runtime-common';
import type { BotTriggerContent } from 'https://cardstack.com/base/matrix-event';
import { createHash } from 'node:crypto';
import type { GitHubClient } from './github';

const log = logger('bot-runner:create-listing-pr');

const DEFAULT_REPO = 'cardstack/boxel-catalog';
const DEFAULT_BASE_BRANCH = 'main';

export type BotTriggerEventContent = BotTriggerContent;

export type CreateListingPRHandler = (args: {
eventContent: BotTriggerEventContent;
runAs: string;
githubClient: GitHubClient;
runCommandResult?: { cardResultString?: string | null } | null;
}) => Promise<void>;

interface CreateListingPRContext {
owner: string;
repoName: string;
repo: string;
head: string;
title: string;
listingDisplayName: string;
}

function getCreateListingPRContext(
eventContent: BotTriggerEventContent,
): CreateListingPRContext | null {
if (eventContent.type !== 'pr-listing-create') {
return null;
}

if (!eventContent.input || typeof eventContent.input !== 'object') {
log.warn('pr-listing-create trigger is missing input payload');
return null;
}

let input = eventContent.input as Record<string, unknown>;
let roomId = typeof input.roomId === 'string' ? input.roomId.trim() : '';
let listingName =
typeof input.listingName === 'string' ? input.listingName.trim() : '';
let listingDisplayName = listingName || 'UntitledListing';
let title =
typeof input.title === 'string' && input.title.trim()
? input.title.trim()
: `Add listing: ${listingDisplayName}`;
let headBranch = toBranchName(roomId, listingDisplayName);

if (!headBranch) {
throw new Error('pr-listing-create trigger must include a valid branch');
}

if (!title) {
log.error('No title for the listing');
return null;
}

let repo = DEFAULT_REPO;

let [owner, repoName] = repo.split('/');
if (!owner || !repoName) {
throw new Error(`Invalid repo format: ${repo}. Expected "owner/repo"`);
}

return {
owner,
repoName,
repo,
head: headBranch,
title,
listingDisplayName,
};
}

export async function ensureCreateListingBranch(args: {
eventContent: BotTriggerEventContent;
githubClient: GitHubClient;
}): Promise<void> {
let context = getCreateListingPRContext(args.eventContent);
if (!context) {
return;
}
try {
await args.githubClient.createBranch({
owner: context.owner,
repo: context.repoName,
branch: context.head,
fromBranch: DEFAULT_BASE_BRANCH,
});
} catch (error) {
let message = error instanceof Error ? error.message : String(error);
if (!message.includes('Reference already exists')) {
throw error;
}
}
}

export async function addContentsToCommit(args: {
eventContent: BotTriggerEventContent;
githubClient: GitHubClient;
runCommandResult?: { cardResultString?: string | null } | null;
}): Promise<void> {
let context = getCreateListingPRContext(args.eventContent);
if (!context) {
return;
}
let branchWrite = await getContentsFromRealm(
args.runCommandResult?.cardResultString,
);
if (branchWrite.files.length === 0) {
return;
}
await args.githubClient.writeFilesToBranch({
owner: context.owner,
repo: context.repoName,
branch: context.head,
files: branchWrite.files,
message: `chore: add submission output [boxel-content-hash:${branchWrite.hash}]`,
});
}

export const openCreateListingPR: CreateListingPRHandler = async ({
eventContent,
runAs,
githubClient,
}) => {
let context = getCreateListingPRContext(eventContent);
if (!context) {
return;
}
let { owner, repoName, repo, head, title, listingDisplayName } = context;

try {
let prParams = {
owner,
repo: repoName,
title,
head,
base: DEFAULT_BASE_BRANCH,
};
let prOptions = {
label: runAs,
};
let result = await githubClient.openPullRequest(prParams, prOptions);

log.info('opened PR from pr-listing-create trigger', {
runAs,
repo,
prUrl: result.html_url,
});
} catch (error) {
let message = error instanceof Error ? error.message : String(error);
if (message.includes('No commits between')) {
log.info('cannot open PR because branch has no commits beyond base', {
runAs,
repo,
head,
listingDisplayName,
error: message,
});
return;
}

if (message.includes('A pull request already exists')) {
log.info('PR already exists for submission branch', {
runAs,
repo,
head,
error: message,
});
return;
}

log.error('failed to open PR from pr-listing-create trigger', {
runAs,
repo,
head,
error: message,
});
throw error;
}

return;
};

async function getContentsFromRealm(cardResultString?: string | null): Promise<{
files: { path: string; content: string }[];
hash: string;
}> {
if (!cardResultString || !cardResultString.trim()) {
return { files: [], hash: hashFiles([]) };
}

let parsed = parseJSONLike(cardResultString);
if (parsed === undefined) {
return { files: [], hash: hashFiles([]) };
}

let files = extractFileContents(parsed);
return { files, hash: hashFiles(files) };
}

function parseJSONLike(value: string): unknown | undefined {
let current: unknown = value;
for (let i = 0; i < 3; i++) {
if (typeof current !== 'string') {
return current;
}
let text = current.trim();
if (!text) {
return undefined;
}
try {
current = JSON.parse(text);
continue;
} catch {
try {
current = JSON.parse(decodeURIComponent(text));
continue;
} catch {
return undefined;
}
}
}
return current;
}

function extractFileContents(
doc: unknown,
): { path: string; content: string }[] {
if (!doc || typeof doc !== 'object') {
return [];
}
let root = doc as Record<string, unknown>;
let attributes = (root.data as Record<string, unknown> | undefined)
?.attributes as Record<string, unknown> | undefined;
let items = attributes?.allFileContents;
if (!Array.isArray(items)) {
items = attributes?.filesWithContent;
}
if (!Array.isArray(items)) {
return [];
}
let dedupe = new Map<string, { path: string; content: string }>();
for (let item of items) {
let normalized =
typeof item === 'string' ? parseJSONLike(item) : (item as unknown);
if (!normalized || typeof normalized !== 'object') {
continue;
}
let record = normalized as Record<string, unknown>;
let path =
typeof record.filename === 'string'
? record.filename.trim()
: typeof record.path === 'string'
? record.path.trim()
: '';
let content =
typeof record.contents === 'string'
? record.contents
: typeof record.content === 'string'
? record.content
: '';
if (!path) {
continue;
}
dedupe.set(path, { path, content });
}
return [...dedupe.values()];
}

function hashFiles(files: { path: string; content: string }[]): string {
let normalized = files
.slice()
.sort((a, b) => a.path.localeCompare(b.path))
.map((file) => `${file.path}\n${file.content}`)
.join('\n---\n');
return createHash('sha256').update(normalized).digest('hex').slice(0, 12);
}
Loading
Loading