diff --git a/packages/bot-runner/lib/command-runner.ts b/packages/bot-runner/lib/command-runner.ts index eb76b0c2eec..c2d4e619312 100644 --- a/packages/bot-runner/lib/command-runner.ts +++ b/packages/bot-runner/lib/command-runner.ts @@ -11,16 +11,22 @@ import { import { enqueueRunCommandJob } from '@cardstack/runtime-common/jobs/run-command'; import { CreateListingPRHandler, + type CreatedListingPRResult, type BotTriggerEventContent, } from './create-listing-pr-handler'; import type { GitHubClient } from './github'; const log = logger('bot-runner'); +const CREATE_PR_CARD_COMMAND = + '@cardstack/catalog/commands/create-pr-card/default'; +const PATCH_CARD_INSTANCE_COMMAND = + '@cardstack/boxel-host/commands/patch-card-instance/default'; export class CommandRunner { private createListingPRHandler: CreateListingPRHandler; constructor( + private submissionBotUserId: string, private dbAdapter: DBAdapter, private queuePublisher: QueuePublisher, githubClient: GitHubClient, @@ -94,12 +100,20 @@ export class CommandRunner { eventContent, result, ); - await this.createListingPRHandler.openCreateListingPR( + let prResult = await this.createListingPRHandler.openCreateListingPR( eventContent, runAs, result, submissionCardUrl, ); + if (prResult && submissionCardUrl) { + await this.createAndLinkPrCard({ + runAs, + realmURL, + submissionCardUrl, + prResult, + }); + } return result; } @@ -145,6 +159,53 @@ export class CommandRunner { return await job.done; } + private async createAndLinkPrCard({ + runAs, + realmURL, + submissionCardUrl, + prResult, + }: { + runAs: string; + realmURL: string; + submissionCardUrl: string; + prResult: CreatedListingPRResult; + }): Promise { + let submissionRealm = new URL('/submissions/', realmURL).href; + let prCardResult = await this.enqueueRunCommand({ + runAs: this.submissionBotUserId, + realmURL: submissionRealm, + command: CREATE_PR_CARD_COMMAND, + commandInput: { + realm: submissionRealm, + prNumber: prResult.prNumber, + prUrl: prResult.prUrl, + prTitle: prResult.prTitle, + branchName: prResult.branchName, + prSummary: prResult.summary, + submittedBy: runAs, + }, + }); + + let prCardUrl = getCardUrl(prCardResult.cardResultString); + await this.enqueueRunCommand({ + runAs, + realmURL, + command: PATCH_CARD_INSTANCE_COMMAND, + commandInput: { + cardId: submissionCardUrl, + patch: { + relationships: { + prCard: { + links: { + self: prCardUrl, + }, + }, + }, + }, + }, + }); + } + private async getCommandsForRegistration( registrationId: string, ): Promise<{ type: string; command: string }[]> { @@ -168,7 +229,7 @@ export class CommandRunner { } } -function getSubmissionCardUrl( +function getCardUrl( cardResultString?: string | null, ): string | null { if (!cardResultString || !cardResultString.trim()) { @@ -182,3 +243,9 @@ function getSubmissionCardUrl( return null; } } + +function getSubmissionCardUrl( + cardResultString?: string | null, +): string | null { + return getCardUrl(cardResultString); +} diff --git a/packages/bot-runner/lib/create-listing-pr-handler.ts b/packages/bot-runner/lib/create-listing-pr-handler.ts index 3591f722b76..25b733a56b4 100644 --- a/packages/bot-runner/lib/create-listing-pr-handler.ts +++ b/packages/bot-runner/lib/create-listing-pr-handler.ts @@ -5,7 +5,7 @@ import { } from '@cardstack/runtime-common'; import type { BotTriggerContent } from 'https://cardstack.com/base/matrix-event'; import { createHash } from 'node:crypto'; -import type { GitHubClient } from './github'; +import type { GitHubClient, OpenPullRequestResult } from './github'; const log = logger('bot-runner:create-listing-pr'); @@ -24,6 +24,14 @@ interface CreateListingPRContext { roomId: string; } +export interface CreatedListingPRResult { + prNumber: number; + prUrl: string; + prTitle: string; + branchName: string; + summary: string | null; +} + function getCreateListingPRContext( eventContent: BotTriggerEventContent, ): CreateListingPRContext | null { @@ -126,15 +134,15 @@ export class CreateListingPRHandler { runAs: string, runCommandResult?: RunCommandResponse | null, submissionCardUrl?: string | null, - ): Promise { + ): Promise { let context = getCreateListingPRContext(eventContent); if (!context) { - return; + return null; } let { owner, repoName, repo, head, title, listingDisplayName } = context; try { - let body = await this.getSubmissionSummary( + let summary = await this.getSubmissionSummary( eventContent, runAs, runCommandResult, @@ -146,7 +154,7 @@ export class CreateListingPRHandler { title, head, base: DEFAULT_BASE_BRANCH, - body: body ?? undefined, + body: summary ?? undefined, }; let result = await this.githubClient.openPullRequest(prParams); @@ -155,6 +163,7 @@ export class CreateListingPRHandler { repo, prUrl: result.html_url, }); + return mapOpenPullRequestResult(result, title, head, summary); } catch (error) { let message = error instanceof Error ? error.message : String(error); if (message.includes('No commits between')) { @@ -165,7 +174,7 @@ export class CreateListingPRHandler { listingDisplayName, error: message, }); - return; + return null; } if (message.includes('A pull request already exists')) { @@ -175,7 +184,7 @@ export class CreateListingPRHandler { head, error: message, }); - return; + return null; } log.error('failed to open PR from pr-listing-create trigger', { @@ -226,6 +235,21 @@ export class CreateListingPRHandler { } } +function mapOpenPullRequestResult( + result: OpenPullRequestResult, + prTitle: string, + branchName: string, + summary: string | null, +): CreatedListingPRResult { + return { + prNumber: result.number, + prUrl: result.html_url, + prTitle, + branchName, + summary, + }; +} + async function getContentsFromRealm(cardResultString?: string | null): Promise<{ files: { path: string; content: string }[]; hash: string; diff --git a/packages/bot-runner/lib/timeline-handler.ts b/packages/bot-runner/lib/timeline-handler.ts index 498abf2a130..5be51a16ade 100644 --- a/packages/bot-runner/lib/timeline-handler.ts +++ b/packages/bot-runner/lib/timeline-handler.ts @@ -32,7 +32,12 @@ export function onTimelineEvent({ githubClient, startTime, }: TimelineHandlerOptions) { - let commandRunner = new CommandRunner(dbAdapter, queuePublisher, githubClient); + let commandRunner = new CommandRunner( + authUserId, + dbAdapter, + queuePublisher, + githubClient, + ); return async function handleTimelineEvent( event: MatrixEvent, room: Room | undefined, diff --git a/packages/bot-runner/tests/bot-runner-test.ts b/packages/bot-runner/tests/bot-runner-test.ts index bf4e9a1523b..04099e847f8 100644 --- a/packages/bot-runner/tests/bot-runner-test.ts +++ b/packages/bot-runner/tests/bot-runner-test.ts @@ -311,8 +311,8 @@ module('timeline handler', () => { assert.strictEqual( publishedJobs.length, - 1, - 'enqueues run-command job for pr-listing-create', + 3, + 'enqueues submission, PR-card, and submission patch run-command jobs for pr-listing-create', ); assert.strictEqual( createdBranches.length, diff --git a/packages/bot-runner/tests/command-runner-test.ts b/packages/bot-runner/tests/command-runner-test.ts index dca9fded8e6..134a73ee6d7 100644 --- a/packages/bot-runner/tests/command-runner-test.ts +++ b/packages/bot-runner/tests/command-runner-test.ts @@ -64,7 +64,12 @@ module('command runner', () => { getColumnNames: async () => [], } as DBAdapter; - let commandRunner = new CommandRunner(dbAdapter, queuePublisher, githubClient); + let commandRunner = new CommandRunner( + '@submissionbot:localhost', + dbAdapter, + queuePublisher, + githubClient, + ); let result = await commandRunner.maybeEnqueueCommand( '@alice:localhost', { @@ -101,23 +106,40 @@ module('command runner', () => { let publishedJobs: unknown[] = []; let submissionCardUrl = 'http://localhost:4201/submissions/SubmissionCard/abc-123'; + let prCardUrl = 'http://localhost:4201/test/PrCard/pr-1'; let queuePublisher: QueuePublisher = { publish: async (job: unknown) => { publishedJobs.push(job); + if (publishedJobs.length === 1) { + return { + id: 1, + done: Promise.resolve({ + status: 'ready', + cardResultString: JSON.stringify({ + data: { + id: submissionCardUrl, + attributes: { + allFileContents: [ + { + filename: 'catalog/MyListing/listing.json', + contents: '{"data":{"type":"card"}}', + }, + ], + }, + }, + }), + }), + } as any; + } return { - id: 1, + id: 2, done: Promise.resolve({ status: 'ready', cardResultString: JSON.stringify({ data: { - id: submissionCardUrl, + id: prCardUrl, attributes: { - allFileContents: [ - { - filename: 'catalog/MyListing/listing.json', - contents: '{"data":{"type":"card"}}', - }, - ], + prNumber: 1, }, }, }), @@ -180,7 +202,12 @@ module('command runner', () => { getColumnNames: async () => [], } as DBAdapter; - let commandRunner = new CommandRunner(dbAdapter, queuePublisher, githubClient); + let commandRunner = new CommandRunner( + '@submissionbot:localhost', + dbAdapter, + queuePublisher, + githubClient, + ); await commandRunner.maybeEnqueueCommand( '@alice:localhost', { @@ -196,10 +223,62 @@ module('command runner', () => { 'bot-registration-2', ); - assert.strictEqual(publishedJobs.length, 1, 'enqueues run-command job'); + assert.strictEqual( + publishedJobs.length, + 3, + 'enqueues create-submission, create-pr-card, and patch-card-instance jobs', + ); assert.strictEqual(createdBranches.length, 1, 'creates branch'); assert.strictEqual(branchWrites.length, 1, 'writes files to branch'); assert.strictEqual(openedPRs.length, 1, 'opens pull request'); + assert.deepEqual( + (publishedJobs[1] as { args: Record }).args, + { + realmURL: 'http://localhost:4201/submissions/', + realmUsername: '@submissionbot:localhost', + runAs: '@submissionbot:localhost', + command: '@cardstack/catalog/commands/create-pr-card/default', + commandInput: { + realm: 'http://localhost:4201/submissions/', + prNumber: 1, + prUrl: 'https://example/pr/1', + prTitle: 'Add My Listing listing', + branchName: 'room-IWFiYzEyMzpsb2NhbGhvc3Q/my-listing', + prSummary: `## Summary + +- Listing Name: My Listing +- Listing Description: Example listing +- Room ID: \`!abc123:localhost\` +- User ID: \`@alice:localhost\` +- Number of Files: 1 +- Submission Card: [${submissionCardUrl}](${submissionCardUrl})`, + submittedBy: '@alice:localhost', + }, + }, + 'enqueues PR card creation in submissions realm', + ); + assert.deepEqual( + (publishedJobs[2] as { args: Record }).args, + { + realmURL: 'http://localhost:4201/test/', + realmUsername: '@alice:localhost', + runAs: '@alice:localhost', + command: '@cardstack/boxel-host/commands/patch-card-instance/default', + commandInput: { + cardId: submissionCardUrl, + patch: { + relationships: { + prCard: { + links: { + self: prCardUrl, + }, + }, + }, + }, + }, + }, + 'enqueues submission card patch in the user realm', + ); let prBody = (openedPRs[0] as { params: Record }).params.body?.toString() ?? ''; assert.true( prBody.includes(`[${submissionCardUrl}](${submissionCardUrl})`), @@ -207,6 +286,104 @@ module('command runner', () => { ); }); + test('does not enqueue PR card creation when PR is not opened', async (assert) => { + let publishedJobs: unknown[] = []; + let queuePublisher: QueuePublisher = { + publish: async (job: unknown) => { + publishedJobs.push(job); + return { + id: 1, + done: Promise.resolve({ + status: 'ready', + cardResultString: JSON.stringify({ + data: { + id: 'http://localhost:4201/submissions/SubmissionCard/abc-123', + attributes: { + allFileContents: [ + { + filename: 'catalog/MyListing/listing.json', + contents: '{"data":{"type":"card"}}', + }, + ], + }, + }, + }), + }), + } as any; + }, + destroy: async () => {}, + }; + + let githubClient: GitHubClient = { + createBranch: async () => ({ ref: 'refs/heads/test', sha: 'abc123' }), + writeFileToBranch: async () => ({ commitSha: 'def456' }), + writeFilesToBranch: async () => ({ commitSha: 'def456' }), + openPullRequest: async () => { + throw new Error('A pull request already exists for this branch'); + }, + }; + let commandsByRegistrationId = new Map< + string, + Record[] + >([ + [ + 'bot-registration-4', + [ + { + command_filter: { + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'pr-listing-create', + }, + command: '@cardstack/catalog/commands/create-submission/default', + }, + ], + ], + ]); + let dbAdapter = { + kind: 'pg', + isClosed: false, + execute: async (sql: string, opts?: ExecuteOptions) => { + if (sql.includes('FROM bot_commands WHERE bot_id =')) { + let registrationId = opts?.bind?.[0]; + if (typeof registrationId !== 'string') { + return []; + } + return commandsByRegistrationId.get(registrationId) ?? []; + } + return []; + }, + close: async () => {}, + getColumnNames: async () => [], + } as DBAdapter; + + let commandRunner = new CommandRunner( + '@submissionbot:localhost', + dbAdapter, + queuePublisher, + githubClient, + ); + await commandRunner.maybeEnqueueCommand( + '@alice:localhost', + { + type: 'pr-listing-create', + realm: 'http://localhost:4201/test/', + userId: '@alice:localhost', + input: { + roomId: '!abc123:localhost', + listingName: 'My Listing', + }, + }, + 'bot-registration-4', + ); + + assert.strictEqual( + publishedJobs.length, + 1, + 'only the submission command is enqueued when PR creation returns null', + ); + }); + test('propagates run-command error for pr-listing-create and skips github writes', async (assert) => { let publishedJobs: unknown[] = []; let queuePublisher: QueuePublisher = { @@ -277,7 +454,12 @@ module('command runner', () => { getColumnNames: async () => [], } as DBAdapter; - let commandRunner = new CommandRunner(dbAdapter, queuePublisher, githubClient); + let commandRunner = new CommandRunner( + '@submissionbot:localhost', + dbAdapter, + queuePublisher, + githubClient, + ); await assert.rejects( commandRunner.maybeEnqueueCommand( diff --git a/packages/bot-runner/tests/create-listing-pr-handler-test.ts b/packages/bot-runner/tests/create-listing-pr-handler-test.ts index 76998ef188e..b4c935c72f2 100644 --- a/packages/bot-runner/tests/create-listing-pr-handler-test.ts +++ b/packages/bot-runner/tests/create-listing-pr-handler-test.ts @@ -30,19 +30,39 @@ module('create-listing-pr handler', () => { }; let handler = new CreateListingPRHandler(githubClient); - await handler.openCreateListingPR(eventContent, '@alice:localhost', { - status: 'ready', - cardResultString: JSON.stringify({ - data: { - attributes: { - allFileContents: [ - { filename: 'catalog/Listing/listing.json', contents: '{}' }, - { filename: 'catalog/Listing/readme.md', contents: '# readme' }, - ], + let result = await handler.openCreateListingPR( + eventContent, + '@alice:localhost', + { + status: 'ready', + cardResultString: JSON.stringify({ + data: { + attributes: { + allFileContents: [ + { filename: 'catalog/Listing/listing.json', contents: '{}' }, + { filename: 'catalog/Listing/readme.md', contents: '# readme' }, + ], + }, }, - }, - }), - }); + }), + }, + ); + + assert.strictEqual(result?.prNumber, 1, 'returns PR number'); + assert.strictEqual(result?.prUrl, 'https://example.com/pr/1', 'returns PR URL'); + assert.strictEqual( + result?.prTitle, + 'Add My Listing listing', + 'returns PR title', + ); + assert.true( + result?.branchName?.endsWith('/my-listing') ?? false, + 'returns branch name used to open the PR', + ); + assert.true( + result?.summary?.includes('## Summary') ?? false, + 'returns generated summary for downstream consumers', + ); assert.strictEqual(opened.length, 1, 'opens exactly one PR'); let openedCall = opened[0] as { @@ -104,7 +124,7 @@ module('create-listing-pr handler', () => { 'http://localhost:4201/submissions/SubmissionCard/abc-123'; let handler = new CreateListingPRHandler(githubClient); - await handler.openCreateListingPR( + let result = await handler.openCreateListingPR( eventContent, '@alice:localhost', { @@ -122,6 +142,12 @@ module('create-listing-pr handler', () => { }, submissionCardUrl, ); + assert.strictEqual(result?.prNumber, 2, 'returns PR metadata when opened'); + assert.true( + result?.summary?.includes(`[${submissionCardUrl}](${submissionCardUrl})`) ?? + false, + 'returns summary including the submission card URL', + ); assert.strictEqual(opened.length, 1, 'opens exactly one PR'); let body = (opened[0] as { params: Record }).params.body?.toString() ?? ''; @@ -130,4 +156,66 @@ module('create-listing-pr handler', () => { 'summary body includes submission card URL as a markdown link', ); }); + + test('returns null when PR already exists', async (assert) => { + let githubClient: GitHubClient = { + openPullRequest: async () => { + throw new Error('A pull request already exists for this branch'); + }, + createBranch: async () => ({ ref: 'refs/heads/test', sha: 'abc123' }), + writeFileToBranch: async () => ({ commitSha: 'def456' }), + writeFilesToBranch: async () => ({ commitSha: 'def456' }), + }; + + let eventContent: BotTriggerEventContent = { + type: 'pr-listing-create', + realm: 'http://localhost:4201/test/', + userId: '@alice:localhost', + input: { + roomId: '!abc123:localhost', + listingName: 'My Listing', + }, + }; + + let handler = new CreateListingPRHandler(githubClient); + let result = await handler.openCreateListingPR( + eventContent, + '@alice:localhost', + ); + + assert.strictEqual(result, null, 'returns null when PR already exists'); + }); + + test('returns null when branch has no commits beyond base', async (assert) => { + let githubClient: GitHubClient = { + openPullRequest: async () => { + throw new Error('No commits between main and feature-branch'); + }, + createBranch: async () => ({ ref: 'refs/heads/test', sha: 'abc123' }), + writeFileToBranch: async () => ({ commitSha: 'def456' }), + writeFilesToBranch: async () => ({ commitSha: 'def456' }), + }; + + let eventContent: BotTriggerEventContent = { + type: 'pr-listing-create', + realm: 'http://localhost:4201/test/', + userId: '@alice:localhost', + input: { + roomId: '!abc123:localhost', + listingName: 'My Listing', + }, + }; + + let handler = new CreateListingPRHandler(githubClient); + let result = await handler.openCreateListingPR( + eventContent, + '@alice:localhost', + ); + + assert.strictEqual( + result, + null, + 'returns null when no PR can be opened', + ); + }); }); diff --git a/packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json b/packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json new file mode 100644 index 00000000000..0a3d3946e13 --- /dev/null +++ b/packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json @@ -0,0 +1,26 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "SubmissionCardPortal", + "module": "../submission-card/submission-card-portal" + } + }, + "type": "card", + "attributes": { + "cardInfo": { + "name": "Submission Card Portal", + "notes": null, + "summary": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": "../Theme/cardstack" + } + } + } + } +} \ No newline at end of file diff --git a/packages/catalog-realm/catalog-app/components/card-with-hydration.gts b/packages/catalog-realm/catalog-app/components/card-with-hydration.gts index 61416a54361..c9cb6c23501 100644 --- a/packages/catalog-realm/catalog-app/components/card-with-hydration.gts +++ b/packages/catalog-realm/catalog-app/components/card-with-hydration.gts @@ -77,7 +77,7 @@ export class CardWithHydration extends GlimmerComponent { .card:hover { cursor: pointer; - border: 1px solid var(--boxel-purple); + border: 1px solid var(--primary, var(--boxel-purple)); } .cards :deep(.field-component-card.fitted-format) { diff --git a/packages/catalog-realm/commands/create-pr-card.ts b/packages/catalog-realm/commands/create-pr-card.ts index 240c7d20a67..ce6521c7ae6 100644 --- a/packages/catalog-realm/commands/create-pr-card.ts +++ b/packages/catalog-realm/commands/create-pr-card.ts @@ -5,6 +5,7 @@ import { contains, type Theme, } from 'https://cardstack.com/base/card-api'; +import MarkdownField from 'https://cardstack.com/base/markdown'; import StringField from 'https://cardstack.com/base/string'; import NumberField from 'https://cardstack.com/base/number'; import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; @@ -19,6 +20,7 @@ class CreatePrCardInput extends CardDef { @field prUrl = contains(StringField); @field prTitle = contains(StringField); @field branchName = contains(StringField); + @field prSummary = contains(MarkdownField); @field submittedBy = contains(StringField); } @@ -33,7 +35,15 @@ export default class CreatePrCardCommand extends Command< } protected async run(input: CreatePrCardInput): Promise { - let { realm, prNumber, prUrl, prTitle, branchName, submittedBy } = input; + let { + realm, + prNumber, + prUrl, + prTitle, + branchName, + prSummary, + submittedBy, + } = input; let catalogRealmUrl = new RealmPaths(new URL('..', import.meta.url)).url; let card = new PrCard({ @@ -41,6 +51,7 @@ export default class CreatePrCardCommand extends Command< prUrl, prTitle, branchName, + prSummary, submittedBy, submittedAt: new Date(), }); @@ -55,11 +66,11 @@ export default class CreatePrCardCommand extends Command< } // Save the PR card to the submission realm - await new SaveCardCommand(this.commandContext).execute({ + let savedCard = (await new SaveCardCommand(this.commandContext).execute({ card, realm, - }); + })) as PrCard; - return card; + return savedCard; } } diff --git a/packages/catalog-realm/commands/create-submission.ts b/packages/catalog-realm/commands/create-submission.ts index a2239432c37..a37f8cd07f8 100644 --- a/packages/catalog-realm/commands/create-submission.ts +++ b/packages/catalog-realm/commands/create-submission.ts @@ -7,6 +7,7 @@ import { logger, planInstanceInstall, planModuleInstall, + toBranchName, type ListingPathResolver, type LooseSingleCardDocument, type Relationship, @@ -111,10 +112,12 @@ export default class CreateSubmissionCommand extends Command< if (!listing.name) { throw new Error('Missing listing.name for CreateSubmission'); } + let branchName = toBranchName(roomId, listing.name); let submission = new SubmissionCard({ listing, roomId, + branchName, allFileContents: filesWithContent.map( (file) => new FileContentField({ diff --git a/packages/catalog-realm/commands/process-github-event.gts b/packages/catalog-realm/commands/process-github-event.gts index 66a69e01fd2..3a441f3f098 100644 --- a/packages/catalog-realm/commands/process-github-event.gts +++ b/packages/catalog-realm/commands/process-github-event.gts @@ -4,7 +4,6 @@ import { JsonField } from 'https://cardstack.com/base/commands/search-card-resul import { Command } from '@cardstack/runtime-common'; import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; import { GithubEventCard } from '../github-event/github-event'; -import CreatePrCardCommand from './create-pr-card'; class ProcessGithubEventInput extends CardDef { @field eventType = contains(StringField); // from command_filter @@ -32,21 +31,6 @@ export default class ProcessGithubEventCommand extends Command< payload, }); - // When a PR is opened, create the PR card first - if (eventType === 'pull_request' && payload?.action === 'opened') { - let pr = payload.pull_request; - if (pr) { - await new CreatePrCardCommand(this.commandContext).execute({ - realm, - prNumber: pr.number, - prUrl: pr.html_url, - prTitle: pr.title, - branchName: pr.head?.ref, - submittedBy: pr.user?.login, - }); - } - } - await new SaveCardCommand(this.commandContext).execute({ card, realm, diff --git a/packages/catalog-realm/pr-card/components/isolated/mergeable-section.gts b/packages/catalog-realm/pr-card/components/isolated/mergeable-section.gts new file mode 100644 index 00000000000..ece6e19d279 --- /dev/null +++ b/packages/catalog-realm/pr-card/components/isolated/mergeable-section.gts @@ -0,0 +1,105 @@ +import GlimmerComponent from '@glimmer/component'; +import TriangleAlertIcon from '@cardstack/boxel-icons/triangle-alert'; +import CircleCheckIcon from '@cardstack/boxel-icons/circle-check'; + +interface MergeableSectionSignature { + Args: { + isMergeable: boolean; + isClosedOrMerged: boolean; + blockReasons: string[]; + }; +} + +export class MergeableSection extends GlimmerComponent { + +} diff --git a/packages/catalog-realm/pr-card/components/isolated/review-section.gts b/packages/catalog-realm/pr-card/components/isolated/review-section.gts index 6e42a87522a..978538e68e0 100644 --- a/packages/catalog-realm/pr-card/components/isolated/review-section.gts +++ b/packages/catalog-realm/pr-card/components/isolated/review-section.gts @@ -13,18 +13,22 @@ class ReviewStateBadge extends GlimmerComponent { if (this.args.state === 'changes_requested') return 'review-state-badge--changes'; if (this.args.state === 'approved') return 'review-state-badge--approved'; + if (this.args.state === 'unknown') return 'review-state-badge--pending'; return ''; } get label() { if (this.args.state === 'changes_requested') return 'Changes Requested'; if (this.args.state === 'approved') return 'Approved'; + if (this.args.state === 'unknown') return 'Pending Review'; return ''; } get hasState() { return ( - this.args.state === 'changes_requested' || this.args.state === 'approved' + this.args.state === 'changes_requested' || + this.args.state === 'approved' || + this.args.state === 'unknown' ); } @@ -70,6 +74,11 @@ class ReviewStateBadge extends GlimmerComponent { border: 1px solid color-mix(in srgb, var(--chart-1, #28a745) 35%, var(--card, #ffffff)); } + .review-state-badge--pending { + background: color-mix(in srgb, #9a6700 10%, var(--card, #ffffff)); + color: #9a6700; + border: 1px solid color-mix(in srgb, #9a6700 30%, var(--card, #ffffff)); + } } @@ -94,6 +103,9 @@ export class ReviewSection extends GlimmerComponent { if (this.args.reviewState === 'approved') { return 'review-item--approved'; } + if (this.args.reviewState === 'unknown') { + return 'review-item--pending'; + } return ''; } @@ -280,6 +292,14 @@ export class ReviewSection extends GlimmerComponent { var(--card, #ffffff) ); } + .review-item--pending { + background: color-mix(in srgb, #9a6700 8%, var(--card, #ffffff)); + border-color: color-mix(in srgb, #9a6700 25%, var(--card, #ffffff)); + } + .review-item--pending .empty-state-text { + color: #9a6700; + font-weight: 600; + } } diff --git a/packages/catalog-realm/pr-card/components/isolated/summary-section.gts b/packages/catalog-realm/pr-card/components/isolated/summary-section.gts deleted file mode 100644 index bdd6e1e4636..00000000000 --- a/packages/catalog-realm/pr-card/components/isolated/summary-section.gts +++ /dev/null @@ -1,46 +0,0 @@ -import GlimmerComponent from '@glimmer/component'; - -interface SummarySectionSignature { - Args: { - summary: string; - }; -} - -export class SummarySection extends GlimmerComponent { - -} diff --git a/packages/catalog-realm/pr-card/pr-card.gts b/packages/catalog-realm/pr-card/pr-card.gts index 09cd13f779b..820dcf511a5 100644 --- a/packages/catalog-realm/pr-card/pr-card.gts +++ b/packages/catalog-realm/pr-card/pr-card.gts @@ -6,6 +6,7 @@ import { contains, realmURL, } from 'https://cardstack.com/base/card-api'; +import MarkdownField from 'https://cardstack.com/base/markdown'; import NumberField from 'https://cardstack.com/base/number'; import DatetimeField from 'https://cardstack.com/base/datetime'; import GitPullRequestIcon from '@cardstack/boxel-icons/git-pull-request'; @@ -18,7 +19,7 @@ import type { GithubEventCard } from '../github-event/github-event'; import { HeaderSection } from './components/isolated/header-section'; import { CiSection } from './components/isolated/ci-section'; import { ReviewSection } from './components/isolated/review-section'; -import { SummarySection } from './components/isolated/summary-section'; +import { MergeableSection } from './components/isolated/mergeable-section'; import { renderPrActionLabel, @@ -144,11 +145,6 @@ class IsolatedTemplate extends Component { ); } - get prBodySummary() { - let body = this.latestPrEventInstance?.payload?.pull_request?.body?.trim(); - return body || 'No pull request summary provided.'; - } - // ── CI ── get ciItems() { return buildCiItems( @@ -196,6 +192,44 @@ class IsolatedTemplate extends Component { return !!this.latestPrReviewCommentEventInstance; } + // ── Mergeability ── + get isClosed() { + let label = this.latestPrActionLabel; + return label === 'Closed' || label === 'Merged'; + } + + get isDraft() { + return this.latestPrActionLabel === 'Draft'; + } + + get mergeBlockReasons(): string[] { + if (this.isClosed) return []; + let reasons: string[] = []; + if (this.isDraft) { + reasons.push('This pull request is still a work in progress'); + } + let { ciItems } = this; + if (ciItems.some((i) => i.state === 'failure')) { + reasons.push('Some checks were not successful'); + } else if (ciItems.some((i) => i.state === 'in_progress')) { + reasons.push('Some checks are still in progress'); + } + let reviewState = this.latestReviewState; + if (reviewState === 'changes_requested') { + reasons.push('Changes were requested by a reviewer'); + } else if (reviewState !== 'approved') { + reasons.push( + 'At least 1 approving review is required by reviewers with write access', + ); + } + return reasons; + } + + get isMergeable() { + if (this.isClosed) return false; + return this.mergeBlockReasons.length === 0; + } + } @@ -376,11 +429,6 @@ class FittedTemplate extends Component { ); } - get prBodySummary() { - let body = this.latestPrEventInstance?.payload?.pull_request?.body?.trim(); - return body || 'No pull request summary provided.'; - } - // ── CI ── get ciItems() { return buildCiItems( @@ -446,6 +494,21 @@ class FittedTemplate extends Component { return computeLatestReviewState(this.latestReviewByReviewer); } + // ── Mergeability ── + get isClosed() { + let label = this.latestPrActionLabel; + return label === 'Closed' || label === 'Merged'; + } + + get isMergeBlocked() { + if (this.isClosed) return false; + if (this.latestPrActionLabel === 'Draft') return true; + if (this.ciItems.some((i) => i.state === 'failure')) return true; + if (this.ciItems.some((i) => i.state === 'in_progress')) return true; + if (this.latestReviewState !== 'approved') return true; + return false; + } + copyBranchName = async () => { let branchName = this.prBranchName?.trim(); if (!branchName) { @@ -527,16 +590,35 @@ class FittedTemplate extends Component { {{#if (eq this.latestReviewState 'changes_requested')}}
Changes Requested + {{#if this.isMergeBlocked}} + + <:default>Merge blocked + + {{/if}}
{{else if (eq this.latestReviewState 'approved')}}
Approved + {{#if this.isMergeBlocked}} + + <:default>Merge blocked + + {{/if}} +
+ {{else}} +
+ Pending Review + {{#if this.isMergeBlocked}} + + <:default>Merge blocked + + {{/if}}
{{/if}} -
-

{{this.prBodySummary}}

-
+ {{#if @model.prSummary}} + <@fields.prSummary /> + {{/if}} + +} diff --git a/packages/catalog-realm/submission-card/components/card/isolated-template.gts b/packages/catalog-realm/submission-card/components/card/isolated-template.gts new file mode 100644 index 00000000000..c60e5101567 --- /dev/null +++ b/packages/catalog-realm/submission-card/components/card/isolated-template.gts @@ -0,0 +1,455 @@ +import { on } from '@ember/modifier'; + +import { Component, realmURL } from 'https://cardstack.com/base/card-api'; +import type { Query } from '@cardstack/runtime-common'; + +import { eq, or } from '@cardstack/boxel-ui/helpers'; +import { BoxelButton } from '@cardstack/boxel-ui/components'; + +import FileCodeIcon from '@cardstack/boxel-icons/file-code'; +import GitPullRequestIcon from '@cardstack/boxel-icons/git-pull-request'; +import MessageIcon from '@cardstack/boxel-icons/message'; +import GitBranchIcon from '@cardstack/boxel-icons/git-branch'; +import CheckCircleIcon from '@cardstack/boxel-icons/circle-check'; +import XCircleIcon from '@cardstack/boxel-icons/circle-x'; +import ClockIcon from '@cardstack/boxel-icons/clock'; + +import { + buildRealmHrefs, + buildLatestReviewByReviewer, + computeLatestReviewState, + searchEventQuery, +} from '../../../pr-card/utils'; +import type { SubmissionCard } from '../../submission-card'; + +export class IsolatedTemplate extends Component { + get fileCount() { + return this.args.model.allFileContents?.length ?? 0; + } + + get listingName() { + return this.args.model.listing?.name ?? this.args.model.listing?.cardTitle; + } + + get title() { + return this.args.model.cardTitle; + } + + get branchName() { + return this.args.model.branchName; + } + + get roomId() { + return this.args.model.roomId; + } + + get listingImage() { + return this.args.model.listing?.images?.[0]; + } + + openListing = () => { + const listing = this.args.model.listing; + if (listing) { + this.args.viewCard?.(listing, 'isolated'); + } + }; + + get realmHrefs() { + return buildRealmHrefs(this.args.model[realmURL]?.href); + } + + get githubEventCardRef() { + return { + module: new URL('../../../github-event/github-event', import.meta.url) + .href, + name: 'GithubEventCard' as const, + }; + } + + get prReviewEventQuery(): Query | undefined { + const prNumber = this.args.model.prCard?.prNumber; + if (!prNumber) return undefined; + return searchEventQuery( + this.githubEventCardRef, + prNumber, + 'pull_request_review', + ); + } + + prReviewEventData = this.args.context?.getCards( + this, + () => this.prReviewEventQuery, + () => this.realmHrefs, + { isLive: true }, + ); + + get reviewState() { + if (!this.args.model.prCard) return null; + const reviews = buildLatestReviewByReviewer( + this.prReviewEventData?.instances ?? [], + ); + return computeLatestReviewState(reviews); + } + + openPrCard = () => { + if (this.args.model.prCard) { + this.args.viewCard?.(this.args.model.prCard, 'isolated'); + } + }; + + +} + +function isPlural(count: number): boolean { + return count !== 1; +} diff --git a/packages/catalog-realm/submission-card/components/portal/realm-tabs.gts b/packages/catalog-realm/submission-card/components/portal/realm-tabs.gts new file mode 100644 index 00000000000..6ca2bd26a65 --- /dev/null +++ b/packages/catalog-realm/submission-card/components/portal/realm-tabs.gts @@ -0,0 +1,70 @@ +import GlimmerComponent from '@glimmer/component'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; + +import type { RealmMetaField } from 'https://cardstack.com/base/command'; + +import { eq } from '@cardstack/boxel-ui/helpers'; +import { Pill } from '@cardstack/boxel-ui/components'; + +interface RealmTabsSignature { + Args: { + realms: RealmMetaField[]; + selectedRealm: string | null; + onChange: (realm: string | null) => void; + }; +} + +export class RealmTabs extends GlimmerComponent { + +} diff --git a/packages/catalog-realm/submission-card/submission-card-portal.gts b/packages/catalog-realm/submission-card/submission-card-portal.gts new file mode 100644 index 00000000000..1c6c9b8b2e0 --- /dev/null +++ b/packages/catalog-realm/submission-card/submission-card-portal.gts @@ -0,0 +1,320 @@ +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { debounce } from 'lodash'; + +import { + CardDef, + Component, + contains, + field, + realmURL, +} from 'https://cardstack.com/base/card-api'; +import CardList from 'https://cardstack.com/base/components/card-list'; +import type { + GetAllRealmMetasResult, + RealmMetaField, +} from 'https://cardstack.com/base/command'; +import { commandData } from 'https://cardstack.com/base/resources/command-data'; +import StringField from 'https://cardstack.com/base/string'; + +import GetAllRealmMetasCommand from '@cardstack/boxel-host/commands/get-all-realm-metas'; +import BotIcon from '@cardstack/boxel-icons/bot'; + +import { gt } from '@cardstack/boxel-ui/helpers'; +import { + BoxelInput, + LoadingIndicator, + ViewSelector, + type ViewItem, +} from '@cardstack/boxel-ui/components'; +import { + Grid3x3 as GridIcon, + Rows4 as StripIcon, +} from '@cardstack/boxel-ui/icons'; +import { type Query, type getCards } from '@cardstack/runtime-common'; + +import { RealmTabs } from './components/portal/realm-tabs'; + +type ViewOption = 'strip' | 'grid'; + +const SUBMISSION_VIEW_OPTIONS: ViewItem[] = [ + { id: 'strip', icon: StripIcon }, + { id: 'grid', icon: GridIcon }, +]; + +class Isolated extends Component { + @tracked searchText: string = ''; + @tracked selectedView: string = 'grid'; + @tracked selectedRealm: string | null = null; + + private debouncedSetSearch = debounce((value: string) => { + this.searchText = value; + }, 300); + + @action + onSearchInput(value: string) { + this.debouncedSetSearch(value); + } + + @action + setView(id: ViewOption) { + this.selectedView = id; + } + + @action + selectRealm(realm: string | null) { + this.selectedRealm = realm; + } + + allRealmsInfoResource = commandData( + this, + GetAllRealmMetasCommand, + ); + + // All realm URLs known to the host — used as the search scope + get allRealmUrls(): string[] { + const resource = this.allRealmsInfoResource; + if (resource?.isSuccess && resource.cardResult) { + return ( + (resource.cardResult as GetAllRealmMetasResult).results?.map( + (r) => r.url, + ) ?? [] + ); + } + return []; + } + + // Query SubmissionCards across all known realms so we can see which ones + // The filter uses adoptsFrom type matching — it looks for cards whose module/name matches SubmissionCard + submissionDiscovery: ReturnType | undefined = + this.args.context?.getCards( + this, + () => this.baseTypeFilter, + () => this.allRealmUrls, + { isLive: true }, + ); + + get baseTypeFilter(): Query { + return { + filter: { + type: { + module: new URL('./submission-card', import.meta.url).href, + name: 'SubmissionCard', + }, + }, + sort: [{ by: 'createdAt', direction: 'desc' }], + }; + } + + private get currentRealmHrefs(): string[] { + const url = this.args.model[realmURL]; + return url ? [url.href] : []; + } + + private get allRealmMetas(): RealmMetaField[] { + if (!this.allRealmsInfoResource?.isSuccess) return []; + return ( + (this.allRealmsInfoResource.cardResult as GetAllRealmMetasResult) + ?.results ?? [] + ); + } + + // Only realms that actually have SubmissionCard instances, with full meta + get availableRealms(): RealmMetaField[] { + const realmUrlsWithCards = new Set( + (this.submissionDiscovery?.instancesByRealm ?? []).map((r) => r.realm), + ); + return this.allRealmMetas.filter((r) => realmUrlsWithCards.has(r.url)); + } + + get isRealmsReady(): boolean { + return ( + this.allRealmsInfoResource?.isSuccess === true && + this.submissionDiscovery?.isLoading === false + ); + } + + get realmHrefs(): string[] { + const fallback = this.currentRealmHrefs; + if (!this.allRealmsInfoResource?.isSuccess) return fallback; + if (this.selectedRealm) return [this.selectedRealm]; + + const availableUrls = this.availableRealms.map((r) => r.url); + return availableUrls.length > 0 ? availableUrls : fallback; + } + + get query(): Query { + const { filter: baseFilter, sort } = this.baseTypeFilter; + + if (!this.searchText) { + return { filter: baseFilter, sort }; + } + + return { + filter: { + every: [ + baseFilter!, + { + any: [{ contains: { cardTitle: this.searchText } }], + }, + ], + }, + sort, + }; + } + + +} + +export class SubmissionCardPortal extends CardDef { + static displayName = 'Submission Card Portal'; + static prefersWideFormat = true; + static headerColor = '#00ffba'; + static icon = BotIcon; + + @field cardTitle = contains(StringField, { + computeVia: function (this: SubmissionCardPortal) { + return 'Submission Card Portal'; + }, + }); + + static isolated = Isolated; +} diff --git a/packages/catalog-realm/submission-card/submission-card.gts b/packages/catalog-realm/submission-card/submission-card.gts index 8fe3c8c05af..98605481e34 100644 --- a/packages/catalog-realm/submission-card/submission-card.gts +++ b/packages/catalog-realm/submission-card/submission-card.gts @@ -1,5 +1,6 @@ import { CardDef, + Component, FieldDef, contains, containsMany, @@ -7,43 +8,180 @@ import { linksTo, } from 'https://cardstack.com/base/card-api'; import StringField from 'https://cardstack.com/base/string'; + import BotIcon from '@cardstack/boxel-icons/bot'; -import { Listing } from '../catalog-app/listing/listing'; +import FileCodeIcon from '@cardstack/boxel-icons/file-code'; -const GITHUB_BRANCH_URL_PREFIX = - 'https://github.com/cardstack/boxel-catalog/tree/'; +import { Listing } from '../catalog-app/listing/listing'; +import { PrCard } from '../pr-card/pr-card'; -function encodeBranchName(branchName: string): string { - return branchName - .split('/') - .map((segment) => encodeURIComponent(segment)) - .join('/'); -} +import { FittedTemplate } from './components/card/fitted-template'; +import { IsolatedTemplate } from './components/card/isolated-template'; export class FileContentField extends FieldDef { @field filename = contains(StringField); @field contents = contains(StringField); + + static atom = class Atom extends Component { + get filename() { + return this.args.model.filename ?? 'Untitled'; + } + + + }; + + static embedded = class Embedded extends Component { + get filename() { + return this.args.model.filename ?? 'Untitled'; + } + + get preview() { + const contents = this.args.model.contents ?? ''; + return contents.split('\n').slice(0, 6).join('\n'); + } + + get lineCount() { + const contents = this.args.model.contents ?? ''; + return contents ? contents.split('\n').length : 0; + } + + + }; } export class SubmissionCard extends CardDef { static displayName = 'SubmissionCard'; static icon = BotIcon; + static fitted = FittedTemplate; + static isolated = IsolatedTemplate; + @field cardTitle = contains(StringField, { computeVia: function (this: SubmissionCard) { - return this.listing?.name ?? this.listing?.cardTitle ?? 'Untitled Submission'; + return ( + this.listing?.name ?? this.listing?.cardTitle ?? 'Untitled Submission' + ); }, }); @field roomId = contains(StringField); @field branchName = contains(StringField); - @field githubURL = contains(StringField, { - computeVia: function (this: SubmissionCard) { - if (!this.branchName) { - return undefined; - } - return `${GITHUB_BRANCH_URL_PREFIX}${encodeBranchName(this.branchName)}`; - }, - }); + @field prCard = linksTo(() => PrCard); @field listing = linksTo(() => Listing); @field allFileContents = containsMany(FileContentField); } + +function isPlural(count: number): boolean { + return count !== 1; +}