diff --git a/packages/base/command.gts b/packages/base/command.gts index 4d206d63cb2..bbcb6d5df05 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -386,10 +386,11 @@ export class ListingInstallResult extends CardDef { export class CreateListingPRRequestInput extends CardDef { @field realm = contains(RealmField); @field listingId = contains(StringField); + @field listingName = contains(StringField); } export class ListingCreateInput extends CardDef { - @field openCardId = contains(StringField); + @field openCardIds = containsMany(StringField); @field codeRef = contains(CodeRefField); @field targetRealm = contains(RealmField); } diff --git a/packages/base/menu-items.ts b/packages/base/menu-items.ts index d62e9819ece..efe10ce45d9 100644 --- a/packages/base/menu-items.ts +++ b/packages/base/menu-items.ts @@ -6,7 +6,7 @@ import { import CopyCardCommand from '@cardstack/boxel-host/commands/copy-card'; import GenerateExampleCardsCommand from '@cardstack/boxel-host/commands/generate-example-cards'; -import ListingCreateCommand from '@cardstack/boxel-host/commands/listing-create'; +import OpenCreateListingModalCommand from '@cardstack/boxel-host/commands/open-create-listing-modal'; import OpenInInteractModeCommand from '@cardstack/boxel-host/commands/open-in-interact-mode'; import PopulateWithSampleDataCommand from '@cardstack/boxel-host/commands/populate-with-sample-data'; import ShowCardCommand from '@cardstack/boxel-host/commands/show-card'; @@ -155,7 +155,7 @@ export function getDefaultCardMenuItems( }); menuItems = [...menuItems, ...getSampleDataMenuItems(card, params)]; menuItems.push({ - label: `Create Listing with AI`, + label: `Create Listing`, action: async () => { const codeRef = resolveAdoptsFrom(card); if (!codeRef) { @@ -165,9 +165,9 @@ export function getDefaultCardMenuItems( if (!targetRealm) { throw new Error('Unable to determine target realm from card'); } - await new ListingCreateCommand(params.commandContext).execute({ - openCardId: cardId, + await new OpenCreateListingModalCommand(params.commandContext).execute({ codeRef, + openCardIds: [cardId], targetRealm, }); }, diff --git a/packages/bot-runner/lib/github.ts b/packages/bot-runner/lib/github.ts index d5f3775c151..00b85efe511 100644 --- a/packages/bot-runner/lib/github.ts +++ b/packages/bot-runner/lib/github.ts @@ -1,5 +1,5 @@ const GITHUB_API_BASE = 'https://api.github.com'; -const GITHUB_TEAM_REVIEWERS = ['ecosystem-team', 'boxel-developers']; +const GITHUB_TEAM_REVIEWERS = ['ecosystem-team']; export interface OpenPullRequestParams { owner: string; diff --git a/packages/bot-runner/tests/command-runner-test.ts b/packages/bot-runner/tests/command-runner-test.ts index 6d1d40dbb56..8d1b74c47c6 100644 --- a/packages/bot-runner/tests/command-runner-test.ts +++ b/packages/bot-runner/tests/command-runner-test.ts @@ -9,6 +9,9 @@ import type { import type { GitHubClient } from '../lib/github'; import { CommandRunner } from '../lib/command-runner'; +const SUBMISSION_REALM_URL = 'http://localhost:4201/submissions/'; +const SUBMISSION_BOT_USER_ID = '@submissionbot:localhost'; + module('command runner', () => { test('enqueues run-command job for matching trigger', async (assert) => { let publishedJobs: unknown[] = []; @@ -65,7 +68,7 @@ module('command runner', () => { } as DBAdapter; let commandRunner = new CommandRunner( - '@submissionbot:localhost', + SUBMISSION_BOT_USER_ID, dbAdapter, queuePublisher, githubClient, @@ -203,7 +206,7 @@ module('command runner', () => { } as DBAdapter; let commandRunner = new CommandRunner( - '@submissionbot:localhost', + SUBMISSION_BOT_USER_ID, dbAdapter, queuePublisher, githubClient, @@ -234,12 +237,12 @@ module('command runner', () => { assert.deepEqual( (publishedJobs[1] as { args: Record }).args, { - realmURL: 'http://localhost:4201/submissions/', - realmUsername: '@submissionbot:localhost', - runAs: '@submissionbot:localhost', + realmURL: SUBMISSION_REALM_URL, + realmUsername: SUBMISSION_BOT_USER_ID, + runAs: SUBMISSION_BOT_USER_ID, command: '@cardstack/catalog/commands/create-pr-card/default', commandInput: { - realm: 'http://localhost:4201/submissions/', + realm: SUBMISSION_REALM_URL, prNumber: 1, prUrl: 'https://example/pr/1', prTitle: 'Add My Listing Name listing', @@ -351,7 +354,7 @@ module('command runner', () => { } as DBAdapter; let commandRunner = new CommandRunner( - '@submissionbot:localhost', + SUBMISSION_BOT_USER_ID, dbAdapter, queuePublisher, githubClient, @@ -448,7 +451,7 @@ module('command runner', () => { } as DBAdapter; let commandRunner = new CommandRunner( - '@submissionbot:localhost', + SUBMISSION_BOT_USER_ID, dbAdapter, queuePublisher, githubClient, diff --git a/packages/catalog-realm/catalog-app/listing/listing.gts b/packages/catalog-realm/catalog-app/listing/listing.gts index 90d0b733264..284158847ea 100644 --- a/packages/catalog-realm/catalog-app/listing/listing.gts +++ b/packages/catalog-realm/catalog-app/listing/listing.gts @@ -45,7 +45,7 @@ import { listingActions, isReady } from '../resources/listing-actions'; import GetAllRealmMetasCommand from '@cardstack/boxel-host/commands/get-all-realm-metas'; import ListingGenerateExampleCommand from '@cardstack/boxel-host/commands/listing-generate-example'; import ListingUpdateSpecsCommand from '@cardstack/boxel-host/commands/listing-update-specs'; -import CreateListingPRRequestCommand from '@cardstack/boxel-host/commands/create-listing-pr-request'; +import OpenCreatePRModalCommand from '@cardstack/boxel-host/commands/open-create-pr-modal'; import { getMenuItems } from '@cardstack/runtime-common'; @@ -564,6 +564,7 @@ class EmbeddedTemplate extends Component { export class Listing extends CardDef { static displayName = 'Listing'; static headerColor = '#6638ff'; + static isListingDef = true; @field name = contains(StringField); @field summary = contains(MarkdownField); @@ -641,7 +642,7 @@ export class Listing extends CardDef { [getMenuItems](params: GetMenuItemParams): MenuItemOptions[] { let menuItems = super [getMenuItems](params) - .filter((item) => item.label?.toLowerCase() !== 'create listing with ai'); + .filter((item) => item.label?.toLowerCase() !== 'create listing'); if (params.menuContext === 'interact') { const extra = this.getGenerateExampleMenuItem(params); if (extra) { @@ -676,9 +677,10 @@ export class Listing extends CardDef { return { label: 'Make a PR', action: async () => { - await new CreateListingPRRequestCommand(commandContext).execute({ + await new OpenCreatePRModalCommand(commandContext).execute({ listingId: this.id, realm: this[realmURL]!.href, + listingName: this.name, }); }, icon: Package, diff --git a/packages/catalog-realm/commands/create-pr-card.ts b/packages/catalog-realm/commands/create-pr-card.ts index ce6521c7ae6..996b0a1cbdc 100644 --- a/packages/catalog-realm/commands/create-pr-card.ts +++ b/packages/catalog-realm/commands/create-pr-card.ts @@ -1,19 +1,11 @@ -import { Command, RealmPaths } from '@cardstack/runtime-common'; -import { - CardDef, - field, - contains, - type Theme, -} from 'https://cardstack.com/base/card-api'; +import { Command } from '@cardstack/runtime-common'; +import { CardDef, field, contains } 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'; import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; import { PrCard } from '../pr-card/pr-card'; -const GITHUB_PR_THEME_PATH = 'Theme/github-pr-brand-guide'; - class CreatePrCardInput extends CardDef { @field realm = contains(StringField); @field prNumber = contains(NumberField); @@ -44,7 +36,6 @@ export default class CreatePrCardCommand extends Command< prSummary, submittedBy, } = input; - let catalogRealmUrl = new RealmPaths(new URL('..', import.meta.url)).url; let card = new PrCard({ prNumber, @@ -56,15 +47,6 @@ export default class CreatePrCardCommand extends Command< submittedAt: new Date(), }); - // Link the GitHub PR brand guide theme from the catalog realm - let themeCardId = `${catalogRealmUrl}${GITHUB_PR_THEME_PATH}`; - let theme = await new GetCardCommand(this.commandContext).execute({ - cardId: themeCardId, - }); - if (theme) { - card.cardInfo.theme = theme as Theme; - } - // Save the PR card to the submission realm let savedCard = (await new SaveCardCommand(this.commandContext).execute({ card, diff --git a/packages/catalog-realm/submission-card/components/card/isolated-template.gts b/packages/catalog-realm/submission-card/components/card/isolated-template.gts index 91f4e92fc7c..1e0db7464df 100644 --- a/packages/catalog-realm/submission-card/components/card/isolated-template.gts +++ b/packages/catalog-realm/submission-card/components/card/isolated-template.gts @@ -47,6 +47,10 @@ export class IsolatedTemplate extends Component { return this.args.model.roomId; } + get prNumber() { + return this.args.model.prCard?.prNumber; + } + get listingImage() { return this.args.model.listing?.images?.[0]; } @@ -166,7 +170,12 @@ export class IsolatedTemplate extends Component { aria-label='View PR card' {{on 'click' this.openPrCard}} > - View PR + + {{#if this.prNumber}} + View PR #{{this.prNumber}} + {{else}} + View PR + {{/if}} {{/if}} @@ -453,4 +462,3 @@ export class IsolatedTemplate extends Component { } - diff --git a/packages/host/app/commands/index.ts b/packages/host/app/commands/index.ts index 4fd424e63da..95262a08638 100644 --- a/packages/host/app/commands/index.ts +++ b/packages/host/app/commands/index.ts @@ -37,6 +37,8 @@ import * as ListingUpdateSpecsCommandModule from './listing-update-specs'; import * as ListingUseCommandModule from './listing-use'; import * as OneShotLlmRequestCommandModule from './one-shot-llm-request'; import * as OpenAiAssistantRoomCommandModule from './open-ai-assistant-room'; +import * as OpenCreateListingModalCommandModule from './open-create-listing-modal'; +import * as OpenCreatePRModalCommandModule from './open-create-pr-modal'; import * as OpenInInteractModeModule from './open-in-interact-mode'; import * as OpenWorkspaceCommandModule from './open-workspace'; import * as PatchCardInstanceCommandModule from './patch-card-instance'; @@ -256,6 +258,14 @@ export function shimHostCommands(virtualNetwork: VirtualNetwork) { '@cardstack/boxel-host/commands/open-ai-assistant-room', OpenAiAssistantRoomCommandModule, ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/open-create-listing-modal', + OpenCreateListingModalCommandModule, + ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/open-create-pr-modal', + OpenCreatePRModalCommandModule, + ); virtualNetwork.shimModule( '@cardstack/boxel-host/commands/open-workspace', OpenWorkspaceCommandModule, @@ -404,6 +414,8 @@ export const HostCommandClasses: (typeof HostBaseCommand)[] = [ ListingUseCommandModule.default, OneShotLlmRequestCommandModule.default, OpenAiAssistantRoomCommandModule.default, + OpenCreateListingModalCommandModule.default, + OpenCreatePRModalCommandModule.default, OpenInInteractModeModule.default, OpenWorkspaceCommandModule.default, GenerateThemeExampleCommandModule.default, diff --git a/packages/host/app/commands/listing-create.ts b/packages/host/app/commands/listing-create.ts index ce94c134ece..4ec696bbfda 100644 --- a/packages/host/app/commands/listing-create.ts +++ b/packages/host/app/commands/listing-create.ts @@ -112,8 +112,7 @@ export default class ListingCreateCommand extends HostBaseCommand< protected async run( input: BaseCommandModule.ListingCreateInput, ): Promise { - const cardAPI = await this.loadCardAPI(); - let { openCardId, codeRef, targetRealm } = input; + let { openCardIds, codeRef, targetRealm } = input; if (!codeRef) { throw new Error('codeRef is required'); @@ -127,14 +126,17 @@ export default class ListingCreateCommand extends HostBaseCommand< let listingType = await this.guessListingType(codeRef); + let relationships: Record = {}; + if (openCardIds && openCardIds.length > 0) { + openCardIds.forEach((id, index) => { + relationships[`examples.${index}`] = { links: { self: id } }; + }); + } + const listingDoc: LooseSingleCardDocument = { data: { type: 'card', - relationships: openCardId - ? { - 'examples.0': { links: { self: openCardId } }, - } - : {}, + relationships, meta: { adoptsFrom: { module: `${this.catalogRealm}catalog-app/listing/listing`, @@ -144,34 +146,31 @@ export default class ListingCreateCommand extends HostBaseCommand< }, }; const listing = await this.store.add(listingDoc, { realm: targetRealm }); - // Always use the transient symbol-based localId; ignore any persisted id at this stage - const listingId = (listing as any)[(cardAPI as any).localId]; - if (!listingId) { - throw new Error('Failed to create listing card (no localId)'); - } - await this.operatorModeStateService.openCardInInteractMode(listingId); const commandModule = await this.loadCommandModule(); - const listingCard = listing as CardAPI.CardDef; // ensure correct type - const specsPromise = this.linkSpecs( - listingCard, - targetRealm, - openCardId ?? codeRef?.module, - ); + const listingCard = listing as CardAPI.CardDef; + const firstOpenCardId = openCardIds?.[0]; - const promises = [ + const backgroundWork = Promise.all([ this.autoPatchName(listingCard, codeRef), this.autoPatchSummary(listingCard, codeRef), this.autoLinkTag(listingCard), this.autoLinkCategory(listingCard), this.autoLinkLicense(listingCard), - this.autoLinkExample(listingCard, codeRef, openCardId), - specsPromise, - ]; + this.autoLinkExample(listingCard, codeRef, openCardIds), + this.linkSpecs( + listingCard, + targetRealm, + firstOpenCardId ?? codeRef?.module, + ), + ]).catch((error) => { + console.warn('Background autopatch failed:', error); + }); - await Promise.all(promises); const { ListingCreateResult } = commandModule; - return new ListingCreateResult({ listing }); + const result = new ListingCreateResult({ listing }); + (result as any).backgroundWork = backgroundWork; + return result; } private async guessListingType( @@ -254,7 +253,9 @@ export default class ListingCreateCommand extends HostBaseCommand< targetRealm: string, resourceUrl: string, // can be module or card instance id ): Promise { - const url = `${targetRealm}_dependencies?url=${encodeURIComponent(resourceUrl)}`; + const resourceRealm = + this.realm.realmOfURL(new URL(resourceUrl))?.href ?? targetRealm; + const url = `${resourceRealm}_dependencies?url=${encodeURIComponent(resourceUrl)}`; const response = await this.network.authedFetch(url, { headers: { Accept: SupportedMimeType.JSONAPI }, }); @@ -376,7 +377,7 @@ export default class ListingCreateCommand extends HostBaseCommand< private async autoLinkExample( listing: CardAPI.CardDef, codeRef: ResolvedCodeRef, - openCardId?: string, + openCardIds?: string[], ) { const existingExamples = Array.isArray((listing as any).examples) ? ((listing as any).examples as CardAPI.CardDef[]) @@ -396,25 +397,29 @@ export default class ListingCreateCommand extends HostBaseCommand< addCard(existing); } - let exampleCard: CardAPI.CardDef | undefined; - if (openCardId) { - try { - const instance = await this.store.get(openCardId); - if (isCardInstance(instance)) { - exampleCard = instance as CardAPI.CardDef; - } else { - console.warn('autoLinkExample: openCardId is not a card instance', { - openCardId, - }); - } - } catch (error) { - console.warn('autoLinkExample: failed to load openCardId', { - openCardId, - error, - }); - } + if (openCardIds && openCardIds.length > 0) { + await Promise.all( + openCardIds.map(async (openCardId) => { + try { + const instance = await this.store.get(openCardId); + if (isCardInstance(instance)) { + addCard(instance as CardAPI.CardDef); + } else { + console.warn( + 'autoLinkExample: openCardId is not a card instance', + { openCardId }, + ); + } + } catch (error) { + console.warn('autoLinkExample: failed to load openCardId', { + openCardId, + error, + }); + } + }), + ); } else { - // If no openCardId was provided, attempt to find any existing instance of this type. + // If no openCardIds were provided, attempt to find any existing instance of this type. try { const search = new SearchCardsByTypeAndTitleCommand( this.commandContext, @@ -426,7 +431,7 @@ export default class ListingCreateCommand extends HostBaseCommand< (c: any) => c && typeof c.id === 'string' && isCardInstance(c), ); if (first) { - exampleCard = first as CardAPI.CardDef; + addCard(first as CardAPI.CardDef); } } } catch (error) { @@ -437,10 +442,15 @@ export default class ListingCreateCommand extends HostBaseCommand< } } - addCard(exampleCard); - + // Only auto-fill additional examples when the user didn't explicitly choose + const userExplicitlyChose = openCardIds && openCardIds.length > 0; const MAX_EXAMPLES = 4; - if (codeRef && exampleCard && uniqueById.size < MAX_EXAMPLES) { + if ( + !userExplicitlyChose && + codeRef && + uniqueById.size > 0 && + uniqueById.size < MAX_EXAMPLES + ) { try { const searchAndChoose = new SearchAndChooseCommand(this.commandContext); const existingIds = Array.from(uniqueById.keys()); @@ -462,7 +472,7 @@ export default class ListingCreateCommand extends HostBaseCommand< } } catch (error) { console.warn('Failed to auto-link additional examples', { - sourceCardId: exampleCard.id, + codeRef, error, }); } diff --git a/packages/host/app/commands/open-create-listing-modal.ts b/packages/host/app/commands/open-create-listing-modal.ts new file mode 100644 index 00000000000..ef1f4b0d1ca --- /dev/null +++ b/packages/host/app/commands/open-create-listing-modal.ts @@ -0,0 +1,33 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type OperatorModeStateService from '../services/operator-mode-state-service'; + +export default class OpenCreateListingModalCommand extends HostBaseCommand< + typeof BaseCommandModule.ListingCreateInput +> { + @service declare private operatorModeStateService: OperatorModeStateService; + + description = 'Open create listing confirmation modal'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { ListingCreateInput } = commandModule; + return ListingCreateInput; + } + + requireInputFields = ['codeRef', 'targetRealm']; + + protected async run( + input: BaseCommandModule.ListingCreateInput, + ): Promise { + this.operatorModeStateService.showCreateListingModal({ + codeRef: input.codeRef, + targetRealm: input.targetRealm, + openCardIds: input.openCardIds, + }); + } +} diff --git a/packages/host/app/commands/open-create-pr-modal.ts b/packages/host/app/commands/open-create-pr-modal.ts new file mode 100644 index 00000000000..3ce24697558 --- /dev/null +++ b/packages/host/app/commands/open-create-pr-modal.ts @@ -0,0 +1,33 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type OperatorModeStateService from '../services/operator-mode-state-service'; + +export default class OpenCreatePRModalCommand extends HostBaseCommand< + typeof BaseCommandModule.CreateListingPRRequestInput +> { + @service declare private operatorModeStateService: OperatorModeStateService; + + description = 'Open create PR confirmation modal'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { CreateListingPRRequestInput } = commandModule; + return CreateListingPRRequestInput; + } + + requireInputFields = ['realm', 'listingId']; + + protected async run( + input: BaseCommandModule.CreateListingPRRequestInput, + ): Promise { + this.operatorModeStateService.showCreatePRModal({ + realm: input.realm, + listingId: input.listingId, + listingName: input.listingName, + }); + } +} diff --git a/packages/host/app/components/card-instance-picker/index.gts b/packages/host/app/components/card-instance-picker/index.gts new file mode 100644 index 00000000000..2fbdee8e7dc --- /dev/null +++ b/packages/host/app/components/card-instance-picker/index.gts @@ -0,0 +1,54 @@ +import Component from '@glimmer/component'; +import { cached } from '@glimmer/tracking'; + +import { Picker, type PickerOption } from '@cardstack/boxel-ui/components'; + +interface Signature { + Args: { + options: PickerOption[]; + selected: PickerOption[]; + onChange: (selected: PickerOption[]) => void; + label?: string; + maxSelectedDisplay?: number; + placeholder?: string; + }; + Blocks: {}; + Element: HTMLElement; +} + +export default class CardInstancePicker extends Component { + @cached + get selectAllOption(): PickerOption { + return { + id: 'select-all', + label: `Select All (${this.args.options.length})`, + shortLabel: 'All', + type: 'select-all', + }; + } + + get allOptions(): PickerOption[] { + return [this.selectAllOption, ...this.args.options]; + } + + get selected(): PickerOption[] { + return this.args.selected.length > 0 + ? this.args.selected + : [this.selectAllOption]; + } + + +} diff --git a/packages/host/app/components/operator-mode/container.gts b/packages/host/app/components/operator-mode/container.gts index 45653d4104c..e17cd057923 100644 --- a/packages/host/app/components/operator-mode/container.gts +++ b/packages/host/app/components/operator-mode/container.gts @@ -41,6 +41,8 @@ import PrerenderedCardSearch from '../prerendered-card-search'; import { Submodes } from '../submode-switcher'; import ChooseFileModal from './choose-file-modal'; +import CreateListingModal from './create-listing-modal'; +import CreatePRModal from './create-pr-modal'; import type CardService from '../../services/card-service'; import type CommandService from '../../services/command-service'; @@ -143,6 +145,8 @@ export default class OperatorModeContainer extends Component {